FCM Service — Arsitektur
File ini = historical design doc (last update 2026-05-19). Untuk current state (Broadcast Phase 1+3+4 LIVE, Phone Auth Plan C+E DONE, 13+ endpoint live), lihat firebase-service-status.md sebagai source-of-truth aktif.
Status: 🟢 Phase 0–4 DONE (last update 2026-05-19 — Phase 4 final closure)
Owner: Backend Lead
Latest: 2026-05-19 — Phase 4 final GO. Folder direname services/fcm_service →
services/firebase_service, VM merchant_fcm → merchant_firebase, systemd unit
fcm-service → firebase-service. Single source of truth Firebase SDK =
services/firebase_service/internal/push/fcm.go. Note: body doc di bawah masih
banyak referensi nama lama (fcm_service / fcm-service) sebagai historical
context — runbook current state lihat
services/firebase_service/README.md (di repo, di luar Docusaurus).
Dokumen terkait:
notification-event-catalog.md— inventarisasi lengkap 18 event dengan ID katalog NOTIF-* (referensi master untuk channel/sound/TTS/routing per event; di working notes)extraction-plan.md— tahap ekstraksi dari package ke service (di working notes)polqo-migration-plan.md— migrasi ke Polqo (di working notes)transaction-notification-flow.md— flow FCM untuk transaksimobile-merchant-status-mapping.md— event types FCM per status merchant
1. Konteks dan Motivasi
FCM push notification saat ini embedded sebagai package di dalam
merchant_core_api/internal/push/fcm.go. Ini bukan masalah selama
Kesles adalah satu-satunya pengguna dan volume masih rendah. Namun ada
tiga alasan konkrit untuk memisahkannya:
-
Polqo migration target — Polqo adalah notification-as-a-service yang akan menyediakan FCM, WA, dan SMS sebagai layanan multi-tenant. FCM perlu menjadi service mandiri sebelum bisa di-extract ke Polqo.
-
Deployment coupling — Setiap update FCM (credential rotate, channel config, token cleanup) membutuhkan redeploy seluruh
merchant_core_api. Sebagai service terpisah, perubahan FCM tidak menyentuh core API. -
6 cacat sistemik mobile (lihat
apps/mobile_user/plans/reliability-redesign.md) — beberapa cacat seperti token lifecycle, heartbeat, dan dedup memerlukan sisi server yang lebih terisolasi dan bisa di-evolve tanpa merisk seluruh core API.
2. Scope Service
Yang menjadi tanggung jawab firebase-service
- Kirim FCM push ke satu atau banyak token (fire-and-forget)
- Simpan dan resolve push token per user/device
- Deactivate token yang invalid (unregistered, expired)
- Token heartbeat — track
last_seen_atper token - Idempotent UPSERT token saat mobile register ulang
Yang TIDAK menjadi tanggung jawab firebase-service
- Business logic event type (siapa dapat notif apa) — tetap di
merchant_core_api - Lookup merchant atau user — tetap di
merchant_core_api - Template notifikasi / copy teks — tetap di
merchant_core_apiviamerchant_event_messages.go - WhatsApp delivery — tetap di
whatsapp_service
3. Endpoint HTTP
Auth semua endpoint internal: X-Internal-API-Key header.
3.1 Kirim push
POST /internal/fcm/send
Request body:
{
"push_token": "fcm_token_string",
"type": "transaction_info",
"title": "Pembayaran Berhasil",
"body": "QRIS Rp 25.000 masuk",
"amount": "25000",
"screen": "transactions",
"reference_id": "TRX-001",
"merchant_id": "uuid",
"transaction_status": "success"
}
Response 202 Accepted:
{
"status": "sent",
"push_token": "fcm_token_string",
"invalidated": false
}
Response 410 Gone (token invalid, sudah di-deactivate):
{
"status": "invalidated",
"push_token": "fcm_token_string",
"invalidated": true
}
3.2 Kirim push ke banyak token (bulk)
POST /internal/fcm/send-bulk
Request body:
{
"push_tokens": ["token_a", "token_b"],
"type": "transaction_info",
"title": "...",
"body": "...",
"amount": "25000",
"screen": "transactions",
"reference_id": "TRX-001",
"merchant_id": "uuid",
"transaction_status": "success"
}
Response 202 Accepted:
{
"sent_count": 2,
"failed_count": 0,
"invalidated_count": 0,
"results": [
{ "push_token": "token_a", "status": "sent", "invalidated": false },
{ "push_token": "token_b", "status": "sent", "invalidated": false }
]
}
3.3 Register / update push token
POST /internal/fcm/tokens
Idempotent UPSERT. Jika token sudah ada, update last_seen_at + is_active = true.
Request body:
{
"user_id": "uuid",
"push_token": "fcm_token_string",
"platform_code": "android",
"device_id": "device-uuid",
"app_version": "1.4.2"
}
Response 200 OK:
{
"token_id": "uuid",
"user_id": "uuid",
"push_token": "fcm_token_string",
"is_active": true,
"registered_at": "2026-05-04T10:00:00Z",
"last_seen_at": "2026-05-04T10:00:00Z"
}
3.4 List token aktif per user
GET /internal/fcm/tokens?user_id={uuid}
GET /internal/fcm/tokens?merchant_id={uuid}
Response 200 OK:
{
"tokens": [
{
"token_id": "uuid",
"user_id": "uuid",
"push_token": "fcm_token_string",
"platform_code": "android",
"device_id": "device-uuid",
"is_active": true,
"last_seen_at": "2026-05-04T10:00:00Z"
}
]
}
3.5 Deactivate token
DELETE /internal/fcm/tokens/{push_token}
Digunakan oleh merchant_core_api kalau ada laporan token invalid dari
sumber lain (mis. mobile logout eksplisit).
Response 200 OK:
{ "status": "deactivated" }
3.6 Health check (liveness)
GET /health
Always returns 200 OK selama process up. Tidak ping dependency — pakai
ini untuk liveness probe (Kubernetes-style) atau script monitor uptime.
{
"service": "firebase-service",
"status": "ok",
"firebase_configured": true,
"postgres_configured": true,
"app_env": "production"
}
3.7 Readiness check (dependency ping)
GET /ready
Berbeda dengan /health, endpoint ini ping dependency:
- DB ping (
SELECT 1kenotification.fcm_push_tokens) - Firebase OAuth2 token fetch (validate service account credentials)
Response 200 OK kalau semua green; 503 Service Unavailable kalau salah satu
fail. Pakai ini sebagai pre-deploy smoke test atau readiness probe load balancer.
{
"service": "firebase-service",
"ready": true,
"postgres": "ok",
"firebase": "ok"
}
4. Push Payload Contract
FCM message yang dikirim ke Firebase menggunakan HTTP v1 API
(https://fcm.googleapis.com/v1/projects/{project}/messages:send).
4.1 Tipe event dan routing mobile
type | Android channel | Sound | Screen default | Tab |
|---|---|---|---|---|
transaction_info | kesles_transaction_v2 | default | transactions | 1 |
login_alert | kesles_general_v2 | silent | profile | 4 |
news | kesles_general_v2 | silent | home | 2 |
merchant_status_* | kesles_general_v2 | silent | profile | 4 |
order_* | kesles_general_v2 | silent | device_monitor | 3 |
4.2 Data payload wajib
{
"type": "transaction_info",
"title": "Pembayaran Berhasil",
"body": "QRIS Rp 25.000 masuk",
"screen": "transactions",
"tab_index": "1",
"payload_version": "1"
}
4.3 Data payload opsional
{
"reference_id": "TRX-001",
"amount": "25000",
"gross_amount": "25000",
"merchant_id": "uuid",
"transaction_status": "success"
}
4.4 TTS (Text-to-Speech) di mobile — bukan tanggung jawab service
Voice announcement transaksi (mis. "Transaksi sukses senilai dua puluh
ribu rupiah") murni client-side di aplikasi merchant_mobile_user.
Trigger-nya bukan field khusus dari payload FCM — TTS dijalankan oleh
transaction_voice_announcement_service.dart
saat onMessage listener menerima payload yang memenuhi:
data.type == "transaction_info", dandata.transaction_status∈{success, failed, pending, cancelled, refunded, reversed}(mobile audibleStatuses set)
firebase-service cukup meneruskan type dan transaction_status di data
payload. Tidak ada flag server-side yang men-disable TTS; mobile yang
gate via setting "Suara transaksi" di profile user.
Implikasi untuk service:
- Jangan dimodifikasi
typejadi non-transaction_infountuk warning yang tidak ingin di-TTS — itu juga akan men-disable Notification Center inbox routing di mobile. - Kalau muncul kebutuhan FCM yang masuk inbox tapi silent TTS (mis.
warning "Merchant Belum Aktif"), introduce
typebaru (mis.merchant_warning) di service + tambah cabang di mobile audibleStatuses guard. Jangan numpangtype=transaction_infodengan status non-success — TTS akan tetap announce.
5. Token Lifecycle
5.1 Register
Mobile memanggil endpoint register token setiap kali:
- App pertama kali diinstall
- App di-reinstall / update
- FCM token di-refresh oleh Firebase
- User login (pastikan token terdaftar untuk session baru)
5.2 Heartbeat
Token di-update last_seen_at setiap kali:
- Push berhasil diterima mobile (implicit — token masih valid)
- Mobile memanggil
/internal/fcm/tokenssaat app resume
Token yang last_seen_at > 30 hari tanpa update dianggap stale
(kandidat cleanup — tidak langsung dideactivate).
5.3 Invalidasi
Token di-deactivate (is_active = false) saat:
- FCM API returns error
UNREGISTERED/INVALID_ARGUMENT - Mobile memanggil DELETE
/internal/fcm/tokens/{token}saat logout
5.4 Token invalid detection
func IsInvalidTokenError(err error) bool {
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unregistered") ||
strings.Contains(msg, "registration token is not a valid fcm registration token") ||
strings.Contains(msg, "requested entity was not found") ||
strings.Contains(msg, "invalid argument")
}
6. Database
6.1 Tabel notification.fcm_push_tokens (current schema, post-migration 125)
Tabel dipakai bersama oleh merchant_core_api (read-only akses via
fcmservice client) dan services/firebase_service (read + write — single
writer). Migration 125 (125_user_push_tokens_app_version_heartbeat.sql,
applied production 2026-05-12) menambahkan app_version + heartbeat_at.
-- Current schema (db_kesles_merchant, post migration 125)
CREATE TABLE notification.fcm_push_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES iam.users(id),
push_token TEXT NOT NULL,
platform_code VARCHAR(20),
device_id UUID,
provider VARCHAR(30) DEFAULT 'fcm',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ,
-- Migration 125 (2026-05-12):
app_version VARCHAR(20),
heartbeat_at TIMESTAMPTZ
);
-- Indexes:
CREATE UNIQUE INDEX uk_user_push_tokens_provider_push_token
ON notification.fcm_push_tokens (provider, push_token);
CREATE INDEX idx_user_push_tokens_user_id
ON notification.fcm_push_tokens (user_id);
CREATE INDEX idx_user_push_tokens_platform_code
ON notification.fcm_push_tokens (platform_code);
-- Migration 125 — cleanup stale tokens berdasarkan last_seen_at:
CREATE INDEX idx_push_tokens_last_seen
ON notification.fcm_push_tokens (last_seen_at DESC)
WHERE is_active = TRUE;
6.2 Migration history
| Migration | Date applied | Effect |
|---|---|---|
| Pre-extraction (legacy) | < 2026-05-04 | Tabel dibuat dengan id, user_id, push_token, platform_code, device_id, provider, is_active, created_at, updated_at, last_seen_at |
125_user_push_tokens_app_version_heartbeat.sql | 2026-05-12 (production) | ADD app_version VARCHAR(20), ADD heartbeat_at TIMESTAMPTZ, CREATE idx_push_tokens_last_seen (partial WHERE is_active=TRUE) |
Catatan: migration ini additive — tidak breaking existing query. Mobile
yang tidak kirim app_version di register request tetap berhasil (NULL OK).
6.3 Idempotent UPSERT query
Query ini dieksekusi di services/firebase_service/internal/store/postgres.go::UpsertPushToken:
INSERT INTO notification.fcm_push_tokens
(user_id, push_token, platform_code, device_id, app_version,
is_active, last_seen_at, updated_at)
VALUES ($1, $2, $3, $4, $5, TRUE, now(), now())
ON CONFLICT (provider, push_token)
DO UPDATE SET
user_id = EXCLUDED.user_id,
platform_code = EXCLUDED.platform_code,
device_id = EXCLUDED.device_id,
app_version = EXCLUDED.app_version,
is_active = TRUE,
last_seen_at = now(),
updated_at = now()
RETURNING id, last_seen_at;
7. Auth ke Firebase
firebase-service menggunakan Google OAuth2 JWT (RS256) untuk mendapat
access token ke Firebase Messaging API.
Credentials:
FIREBASE_PROJECT_ID=...
FIREBASE_CLIENT_EMAIL=...
FIREBASE_PRIVATE_KEY=... (PEM, \n escaped)
Scope: https://www.googleapis.com/auth/firebase.messaging
Token TTL: 1 jam (di-cache in-memory, re-fetch sebelum expired)
FCM endpoint: https://fcm.googleapis.com/v1/projects/{project_id}/messages:send
8. Konfigurasi Service
APP_PORT=8093
POSTGRES_DSN=postgres://...
INTERNAL_NOTIFICATION_API_KEY=...
FIREBASE_PROJECT_ID=...
FIREBASE_CLIENT_EMAIL=...
FIREBASE_PRIVATE_KEY=...
Nama env INTERNAL_NOTIFICATION_API_KEY mengikuti konvensi
merchant_core_api (lihat catatan di extraction-plan.md §7 di working notes). dashboard_api saat ini
memakai nama berbeda (CORE_API_INTERNAL_KEY) untuk value yang sama —
ini perbedaan penamaan saja, bukan dua key berbeda.
9. Deployment
firebase-service adalah Go binary terpisah, dideploy sebagai container
atau systemd unit:
- Port:
8093(lokal) - Deploy: docker container / systemd service
- Health probe:
GET /health - Resource: 0.5 CPU, 256 MB RAM (estimasi awal)
- Scaling: horizontal kalau volume push > 50K/hari (belum dibutuhkan saat ini)
10. Caller post-extraction (Phase 4 step 1 — 2026-05-14)
Semua handler push di merchant_core_api sekarang melalui wrapper tunggal
s.dispatchPush(ctx, fcmservice.SendInput{...}) di
push_dispatcher.go.
Wrapper forward HTTP ke firebase-service /internal/fcm/send — tidak ada lagi
embedded Firebase SDK di core_api.
| Handler | Event | Flow |
|---|---|---|
auth_mobile_handlers.go::handleVerifyOTP | login_alert | s.dispatchPush() → HTTP → firebase-service |
internal_notification_handlers.go::handleSendPushTest | push test | s.dispatchPush() → HTTP → firebase-service |
internal_notification_handlers.go::handleSendPSPEventTest | PSP transaction test | s.dispatchPush() → HTTP → firebase-service |
internal_merchant_event_handler.go::dispatchMerchantPush | 14 merchant/order events | s.dispatchPush() → HTTP → firebase-service |
internal_transaction_status_handlers.go::dispatchTransactionPush | transaction_info | s.dispatchPush() → HTTP → firebase-service |
merchant_staff_invitations_handlers.go::notifyOwner* | staff_joined, staff_removed | s.dispatchPush() → HTTP → firebase-service |
psp_push_notifier.go (dashboard_api) | PSP transaction success | HTTP → merchant_core_api /internal/notifications/push/test → s.dispatchPush() → HTTP → firebase-service |
Post-Phase-4 contract caller:
result, err := s.dispatchPush(ctx, fcmservice.SendInput{
PushToken: target.PushToken,
Type: "transaction_info",
Title: title,
Body: body,
// ... field lain
})
if err != nil { /* log; do not deactivate (might be transient) */ }
if result != nil && result.Invalidated {
// FCM return UNREGISTERED → token mati permanen
s.postgresStore.DeactivatePushToken(ctx, target.PushToken)
}
push.IsInvalidTokenError(err) string-match sudah tidak dipakai —
status invalidation di-encode sebagai *SendResult.Invalidated (boolean),
lebih bersih dan deterministic.
11. Evolusi ke Polqo
Saat Polqo migration aktif, firebase-service Kesles menjadi thin proxy
ke polqo-fcm-service untuk traffic Kesles:
merchant_core_api (no Firebase SDK — Phase 4 cleanup 2026-05-14)
│ HTTP /internal/fcm/* (X-Internal-API-Key)
▼
firebase-service Kesles (port 8093) — single SDK home + token store writer
│ HTTP (feature flag: USE_POLQO_FCM=true)
▼
polqo-fcm-service via api.polqo.com
│
▼
Firebase (Polqo project — TERPISAH dari kesles-merchant project)
Selama feature flag USE_POLQO_FCM=false (default selama observation +
gradual cutover), firebase-service Kesles tetap berjalan normal menggunakan
Firebase project Kesles (kredensial cuma ada di services/firebase_service/.env.production).
Implikasi Phase 4 cleanup (2026-05-14) untuk Polqo migration:
merchant_core_apitidak punya Firebase SDK lagi (fileinternal/push/fcm.godihapus + fieldFirebaseProjectID/ClientEmail/PrivateKeydihapus dari config). Rollback ke pre-extraction mode butuhgit revertPhase 4 commit + restore env- rebuild — bukan flag-flip lagi. Itu meningkatkan kepentingan Polqo Phase P1 shadow mode + P2 gradual cutover per event type sebelum P3 full cutover.
- Flag
FCM_TRANSPORT+FCM_TOKEN_STOREdi core_api sudah dihapus. Edge antara core_api → firebase_service Kesles adalah single HTTP path tanpa toggle. - Saat Polqo P3 full cutover, firebase-service Kesles tetap perlu dipertahankan 2-4 minggu sebagai safety net sebelum P4 decommission. Itu satu-satunya fallback FCM yang tersisa.
Catatan penting: Firebase project Polqo harus berbeda dari Firebase project Kesles (lihat ADR 0009 di polqo repo).
12. Dokumen Terkait
extraction-plan.md— tahap dan checklist ekstraksi (di working notes)polqo-migration-plan.md— rencana migrasi ke Polqo (di working notes)shadow-mode-plan.md— panduan Phase 2 shadow mode (di working notes)transaction-notification-flow.mdservices/whatsapp/architecture.md— referensi pola yang sama untuk WA