Lewati ke konten utama

FCM Service — Arsitektur

Historical Design Doc

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_serviceservices/firebase_service, VM merchant_fcmmerchant_firebase, systemd unit fcm-servicefirebase-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 transaksi
  • mobile-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:

  1. 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.

  2. 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.

  3. 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_at per 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_api via merchant_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 1 ke notification.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

typeAndroid channelSoundScreen defaultTab
transaction_infokesles_transaction_v2defaulttransactions1
login_alertkesles_general_v2silentprofile4
newskesles_general_v2silenthome2
merchant_status_*kesles_general_v2silentprofile4
order_*kesles_general_v2silentdevice_monitor3

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", dan
  • data.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 type jadi non-transaction_info untuk 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 type baru (mis. merchant_warning) di service + tambah cabang di mobile audibleStatuses guard. Jangan numpang type=transaction_info dengan 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/tokens saat 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

MigrationDate appliedEffect
Pre-extraction (legacy)< 2026-05-04Tabel 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.sql2026-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.

HandlerEventFlow
auth_mobile_handlers.go::handleVerifyOTPlogin_alerts.dispatchPush() → HTTP → firebase-service
internal_notification_handlers.go::handleSendPushTestpush tests.dispatchPush() → HTTP → firebase-service
internal_notification_handlers.go::handleSendPSPEventTestPSP transaction tests.dispatchPush() → HTTP → firebase-service
internal_merchant_event_handler.go::dispatchMerchantPush14 merchant/order eventss.dispatchPush() → HTTP → firebase-service
internal_transaction_status_handlers.go::dispatchTransactionPushtransaction_infos.dispatchPush() → HTTP → firebase-service
merchant_staff_invitations_handlers.go::notifyOwner*staff_joined, staff_removeds.dispatchPush() → HTTP → firebase-service
psp_push_notifier.go (dashboard_api)PSP transaction successHTTP → merchant_core_api /internal/notifications/push/tests.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_api tidak punya Firebase SDK lagi (file internal/push/fcm.go dihapus + field FirebaseProjectID/ClientEmail/PrivateKey dihapus dari config). Rollback ke pre-extraction mode butuh git revert Phase 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_STORE di 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.md
  • services/whatsapp/architecture.md — referensi pola yang sama untuk WA