๐ Overview
Centralized notification service untuk ekosistem ikavia.com. Menerima event
via RabbitMQ, mengirim notifikasi multi-channel (Telegram, Email/SMTP, &
FCM push aktif; whatsapp/webhook planned), dan menyimpan setiap notifikasi sebagai
inbox record yang bisa di-query per-platform via REST API.
Tech: Go 1.26, MongoDB (inbox + history), RabbitMQ (AMQP consumer dengan
auto-reconnect supervisor). Deploy: PM2, reverse-proxy nginx + Let's
Encrypt di notification.ikavia.com.
> gRPC service (proto/notification.proto, proto/queue.proto) terdefinisi
> di repo tapi belum di-serve pada deployment saat ini โ integrasi
> gunakan AMQP (kirim) dan REST (baca inbox).
๐ฌ Async ingest (RabbitMQ)
Primary integration path untuk MENGIRIM notifikasi
๐ค Multi-channel fan-out
Satu notifikasi โ banyak tujuan
๐ฅ Inbox REST API
Baca/kelola notifikasi tersimpan per platform
๐ฉบ Health probes
Liveness vs readiness yang jujur
Base URL
Constraints
- At-least-once delivery โ idempotensi dijamin service via unique index (platform, id); event tanpa platform/id tetap dikirim tapi tanpa dedup.
- Provider aktif: `telegram` (Bot API), `email` (SMTP via Postfix mail.ikavia.com, DKIM-signed, from noreply@ikavia.com), dan `fcm` (Firebase Cloud Messaging HTTP v1). whatsapp/http masih stub โ event dengan provider tak terdaftar gagal & tercatat `failed` di inbox.
- FCM push: channel `{ type: "fcm", recipient: { fcm_token: "<device token>" }, content: { subject?: <title>, body: <text> } }`. Muncul walau app ditutup penuh (payload `notification` + priority high, ala WhatsApp). Aktif bila server di-set FCM_CREDENTIALS_FILE (service account Firebase) + FCM_PROJECT_ID; app mobile/web harus mengirim device token sebagai `fcm_token`.
- Inbox REST hanya aktif bila MongoDB tersedia; tanpa Mongo service jalan send-only.
- Auth fail-closed: tanpa ACCOUNT_BASE_URL terkonfigurasi, seluruh /api/v1/notifications menolak request.
- List max 100 item per page (limit di-clamp).
- Multi-channel: satu notifikasi fan-out ke beberapa channel (channels[]); satu record di /api/v1/notifications. Channel `inapp` selalu ada & otomatis terkirim (record ini = notifikasi in-app). Status agregat dihitung atas channel EKSTERNAL: sent (semua eksternal terkirim / in-app only) ยท partial (sebagian) ยท failed (semua eksternal gagal, in-app tetap tampil) ยท received (belum diproses).
- Channel eksternal yang gagal di-retry otomatis oleh worker tiap NOTIFICATION_RETRY_INTERVAL sampai NOTIFICATION_RETRY_MAX_ATTEMPTS (default 3). Pengiriman dijamin tidak dobel (claim atomik per-channel) dan setiap duplikat (mis. crash sebelum finalize) dibatasi max attempts.
- Exhaustion: setelah mencapai max attempts, channel berhenti dicoba & tetap `failed` (tidak ada dead-letter queue). Record tetap queryable via `GET /api/v1/notifications?status=failed`. Untuk kirim ulang, publish notifikasi BARU dengan `id` berbeda.
- Idempotensi per-channel: redelivery dengan `(platform, id)` sama TIDAK mengirim ulang channel yang sudah `sent` dan TIDAK me-reset progress. Channel yang berbeda di payload redelivery DIABAIKAN โ channels ditetapkan sekali saat record pertama dibuat. Untuk menambah channel, pakai notifikasi dengan `id` baru.
- save=false: notifikasi tidak dipersist (fire-and-forget) โ channel eksternal dikirim best-effort SEKALI tanpa retry, tidak muncul di /api/v1/notifications, dan channel `inapp` tidak berlaku (tak ada record). Gunakan hanya untuk alert transient yang tak perlu jejak.
- In-app only: notifikasi dengan `channels: []` (atau tanpa channel eksternal) valid โ hanya tersimpan & tampil di /api/v1/notifications, status langsung `sent`. Cocok untuk notifikasi yang murni in-app.
- Kompatibel mundur: payload single-channel lama (provider_type + recipient + content top-level) tetap jalan โ disintesis menjadi `inapp` + 1 channel eksternal. Konsekuensi: producer lama kini melihat `channels[]` di response dan status bisa `partial` (in-app sukses, eksternal gagal) di tempat yang dulu `failed`.
- Knob (env): NOTIFICATION_RETRY_MAX_ATTEMPTS (3) ยท NOTIFICATION_RETRY_INTERVAL (30s) ยท NOTIFICATION_CHANNEL_LEASE (5m, lease klaim 'sending' sebelum bisa direbut worker) ยท NOTIFICATION_RETENTION_DAYS (90, TTL auto-hapus record).
- Multi-instance provider: service bisa menjalankan beberapa instance per tipe (telegram1/2, smtp1/2, fcm_wacca/fcm_shop, dst) โ operator menaruh satu file *.json per provider di PROVIDERS_DIR, tiap file punya `name` unik. Producer memilih instance lewat `provider_name` di channel.
- Aturan pemilihan provider (ketat, anti-salah-kirim): (a) `provider_name` di-set โ harus ada DAN tipenya cocok, kalau tidak event gagal (typo TIDAK diam-diam jatuh ke instance lain). (b) `provider_name` kosong โ dipakai bila tipe itu hanya punya 1 instance; kalau ada >1 instance, event gagal minta `provider_name` eksplisit. Maka: bila satu tipe punya banyak instance, `provider_name` WAJIB.
- URL: REST API publik di `https://notification.ikavia.com/api/v1/...` (nginx strip prefix `/api`, backend melayani `/v1/...`). Health probe `/health` ยท `/ready` ยท `/live` tanpa `/api` & tanpa versi (infra-level). CORS dibuka untuk origin `https://docs.ikavia.com` (API tester di halaman docs ini); method GET/POST/DELETE/OPTIONS, header Authorization/Content-Type/X-Platform.
๐ฌ Async ingest (RabbitMQ)
Service lain publish event ke exchange notification.requests (routing key *.send). Notification service consume dari notification.events.queue, persist dulu ke inbox (store-then-send), lalu fan-out ke semua channel notifikasi (in-app + email + telegram). Idempotent pada (platform, id); channel gagal di-retry otomatis.
๐ค Multi-channel fan-out
Satu event dengan channels[] terkirim ke beberapa channel sekaligus, masing dengan recipient & content sendiri (default title/body + override per-channel). Satu record di /api/v1/notifications dengan status per-channel; inapp selalu ada (record ini = notifikasi in-app).
๐ฅ Inbox REST API
GET /api/v1/notifications (list + filter, dengan channels[] per-status), mark-read, soft-delete. Auth via JWT RS256 dari account-service (fail-closed). Scoping per-platform via header X-Platform. Human principal hanya melihat inbox miliknya; service melihat platform penuh.
๐ฉบ Health probes
/health & /live = liveness murah (proses hidup). /ready = cek dependency nyata: RabbitMQ consumer (gagal โ 503) dan MongoDB (degraded, tidak menggagalkan ready).
๐ Bearer JWT Authentication Flow
Step-by-step authentication menggunakan Bearer JWT
Dapatkan access token RS256 (human login atau service-to-service grant).
Authorization: Bearer <token> dan X-Platform: <platform> (jika token multi-platform/wildcard).
GET /api/v1/notifications mengembalikan inbox sesuai scope: human โ miliknya sendiri (target_id = sub), service โ seluruh platform.
Setiap response membawa header X-Request-ID (di-generate kalau kamu tidak kirim, atau di-echo kalau kamu kirim sendiri untuk propagasi dari gateway). Simpan id ini saat ada error โ server menuliskannya di access-log tiap request (req=<id> METHOD path -> status), jadi tim bisa lacak request spesifik dari log.
๐ Service Flow Diagram
Visualisasi alur data antara services
Satu halaman inbox milik caller, terbaru lebih dulu (urut received_at menurun). Human principal otomatis dikunci ke target_id = subject token; service principal melihat seluruh platform dan boleh narrow via target-id.
Bentuk response. data adalah array record inbox; tiap item selalu memuat field spine yang ter-index (id, platform, target_id, target_type, category, org_id, status, read, read_at, received_at, updated_at), array channels (status pengiriman per-channel), payload (pesan asli producer, verbatim), dan responses (jejak aksi atas notifikasi, mis. accept/decline). meta membawa total (jumlah seluruh record yang cocok filter, bukan cuma halaman ini), limit, dan offset untuk pagination.
Multi-channel. Satu notifikasi (satu record di sini) bisa terkirim ke beberapa channel sekaligus โ inapp, email, telegram โ masing dengan recipient & content sendiri. channels[] menampilkan outcome tiap channel: { type, status, attempts, error?, sent_at? }. Channel inapp selalu ada dan otomatis sent (record ini sendiri = notifikasi in-app). Channel eksternal yang gagal di-retry otomatis sampai batas (NOTIFICATION_RETRY_MAX_ATTEMPTS, default 3).
Catatan field:
status(agregat seluruh channel):received(belum ada channel
yang dikirim) ยท sent (semua channel terkirim) ยท partial (sebagian terkirim, sebagian gagal โ mis. in-app & email sukses tapi telegram gagal) ยท failed (semua channel eksternal gagal).
channels[].status:pendingยทsending(sedang dikirim) ยทsent
ยท failed. attempts = jumlah percobaan; error berisi alasan kegagalan terakhir.
read=truebilaread_atnon-null; keduanya konsisten.payloadbentuknya bebas per producer/provider โ jangan asumsikan
skema tetap; baca by-key.
Contoh request:
# Halaman pertama inbox milik user (human token), 20 terbaru
curl -s https://notification.ikavia.com/api/v1/notifications \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "X-Platform: todo-app"
# Hanya yang belum dibaca, kategori invitation, 10 per halaman
curl -s "https://notification.ikavia.com/api/v1/notifications?unread-only=true&category=invitation&limit=10" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "X-Platform: todo-app"
# Service token: inbox satu user tertentu, hanya yang gagal kirim
curl -s "https://notification.ikavia.com/api/v1/notifications?target-id=acc_01HXAB&status=failed" \
-H "Authorization: Bearer $SERVICE_TOKEN" \
-H "X-Platform: todo-app"Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| limit | int | optional | Item per halaman. Max 100 (di-clamp). (default: 20) |
| offset | int | optional | Pagination offset (jumlah record dilewati). (default: 0) |
| unread-only | bool | optional | true โ hanya record dengan read_at null. |
| status | string | optional | Filter status agregat: received | sent | partial | failed. |
| category | string | optional | Filter by field category producer (mis. invitation, security). |
| target-type | string | optional | Filter by target_type (mis. user, org, group). |
| org-id | string | optional | Hanya untuk Group Inbox: pilih org mana yang dipakai saat melebarkan ke grup (untuk user multi-org). Default = org di token. account_id tetap subjek token (tak bisa di-spoof). Lihat section Group Inbox. |
| target-id | string | optional | Service principal only โ narrow ke satu target. Diabaikan untuk human (selalu dikunci ke subject token). |
| source-service | string | optional | Filter by payload.source_service (service yang publish). |
| provider-type | string | optional | Filter by payload.provider_type (telegram | email | โฆ). |
{
"success": true,
"data": [
{
"id": "evt-pwd-reset-1780531200",
"platform": "todo-app",
"target_id": "acc_01HXAB7Q9M2K",
"target_type": "user",
"category": "security",
"org_id": "org_01HXAA0RT3",
"status": "partial",
"read": false,
"read_at": null,
"received_at": "2026-06-04T04:20:00Z",
"updated_at": "2026-06-04T04:20:02Z",
"channels": [
{ "type": "inapp", "status": "sent", "attempts": 0 },
{ "type": "email", "status": "sent", "attempts": 1, "sent_at": "2026-06-04T04:20:01Z" },
{ "type": "telegram", "status": "failed", "attempts": 3, "error": "telegram API error: chat not found" }
],
"payload": {
"id": "evt-pwd-reset-1780531200",
"platform": "todo-app",
"target_id": "acc_01HXAB7Q9M2K",
"source_service": "account-service",
"title": "Keamanan akun",
"body": "Permintaan reset kata sandi diterima.",
"channels": [
{ "type": "email", "provider_name": "smtp_email",
"recipient": { "email": "rian@example.com", "name": "Rian" },
"content": { "subject": "Reset kata sandi Anda", "body": "Klik tautan berikut. Berlaku 15 menit." } },
{ "type": "telegram", "provider_name": "telegram_bot",
"recipient": { "telegram_chat_id": "932004505" },
"content": { "body": "๐ Permintaan reset kata sandi diterima." } }
],
"metadata": { "event": "password_reset_requested" },
"created_at": "2026-06-04T04:20:00Z"
},
"responses": []
},
{
"id": "evt-invite-1780528800",
"platform": "todo-app",
"target_id": "acc_01HXAB7Q9M2K",
"target_type": "user",
"category": "invitation",
"org_id": "org_01HXAA0RT3",
"status": "sent",
"read": true,
"read_at": "2026-06-04T03:45:10Z",
"received_at": "2026-06-04T03:40:00Z",
"updated_at": "2026-06-04T03:46:00Z",
"channels": [
{ "type": "inapp", "status": "sent", "attempts": 0 },
{ "type": "telegram", "status": "sent", "attempts": 1, "sent_at": "2026-06-04T03:40:01Z" }
],
"payload": {
"id": "evt-invite-1780528800",
"platform": "todo-app",
"target_id": "acc_01HXAB7Q9M2K",
"source_service": "account-service",
"provider_type": "telegram",
"provider_name": "telegram_bot",
"recipient": { "telegram_chat_id": "932004505" },
"content": {
"subject": "๐จ Undangan ke organisasi Acme",
"body": "Vincent mengundang Anda bergabung ke <b>Acme</b> sebagai Editor."
},
"actions": [
{ "key": "accept", "label": "Terima" },
{ "key": "decline", "label": "Tolak" }
],
"metadata": { "event": "org_invitation", "invitation_id": "inv_01HXAC55" },
"created_at": "2026-06-04T03:40:00Z"
},
"responses": [
{
"action_key": "accept",
"data": { "invitation_id": "inv_01HXAC55", "role": "editor" },
"at": "2026-06-04T03:46:00Z",
"by": "acc_01HXAB7Q9M2K"
}
]
},
{
"id": "evt-receipt-1780524000",
"platform": "todo-app",
"target_id": "acc_01HXAB7Q9M2K",
"target_type": "user",
"category": "billing",
"org_id": "org_01HXAA0RT3",
"status": "failed",
"read": false,
"read_at": null,
"received_at": "2026-06-04T02:20:00Z",
"updated_at": "2026-06-04T02:20:01Z",
"channels": [
{ "type": "inapp", "status": "sent", "attempts": 0 },
{ "type": "email", "status": "failed", "attempts": 3, "error": "provider smtp_email failed to send: dial tcp mail.ikavia.com:587: i/o timeout" }
],
"payload": {
"id": "evt-receipt-1780524000",
"platform": "todo-app",
"target_id": "acc_01HXAB7Q9M2K",
"source_service": "billing-service",
"provider_type": "email",
"provider_name": "smtp_email",
"recipient": { "email": "rian@example.com" },
"content": { "subject": "Struk pembayaran #INV-2026-0412", "body": "Terlampir struk Anda." },
"created_at": "2026-06-04T02:20:00Z"
},
"responses": []
}
],
"meta": { "total": 137, "limit": 20, "offset": 0 }
}
# โโโ Contoh response halaman kosong (mis. ?unread-only=true tapi semua sudah dibaca) โโโ
# {
# "success": true,
# "data": [],
# "meta": { "total": 0, "limit": 20, "offset": 0 }
# }
# โโโ Contoh error: token hilang / invalid (401) โโโ
# { "success": false, "message": "unauthenticated" }
#
# โโโ Contoh error: token wildcard/multi-platform tanpa X-Platform (400) โโโ
# { "success": false, "message": "wildcard grant; select a platform via the X-Platform header or ?platform=" }
Jumlah notifikasi belum dibaca milik caller โ untuk badge angka di app. Count-only (tidak menarik record, jadi murah & cocok dipanggil sering; di-index khusus agar proporsional ke jumlah unread). Owner-scoped: human โ miliknya sendiri. Karena badge selalu per-user, principal service wajib menyertakan ?target-id=<account> (kalau tidak โ 400, mencegah hitung seluruh platform). Hanya menghitung record yang tampil di inbox in-app (record yang channel inapp-nya dimute user tidak ikut), konsisten dengan GET /api/v1/notifications.
{ "success": true, "data": { "unread": 7 } }
# โโโ 400: service principal tanpa target-id โโโ
# { "success": false, "message": "target-id is required" }
Detail satu record. Bentuk item identik dengan satu elemen data[] pada List (spine + payload verbatim + responses). 404 (notification not found) jika id tidak ada atau di luar scope caller (human hanya boleh id miliknya sendiri).
{
"success": true,
"data": {
"id": "evt-invite-1780528800",
"platform": "todo-app",
"target_id": "acc_01HXAB7Q9M2K",
"target_type": "user",
"category": "invitation",
"org_id": "org_01HXAA0RT3",
"status": "sent",
"read": true,
"read_at": "2026-06-04T03:45:10Z",
"received_at": "2026-06-04T03:40:00Z",
"updated_at": "2026-06-04T03:46:00Z",
"channels": [
{ "type": "inapp", "status": "sent", "attempts": 0 },
{ "type": "telegram", "status": "sent", "attempts": 1, "sent_at": "2026-06-04T03:40:01Z" }
],
"payload": {
"id": "evt-invite-1780528800",
"platform": "todo-app",
"target_id": "acc_01HXAB7Q9M2K",
"source_service": "account-service",
"provider_type": "telegram",
"provider_name": "telegram_bot",
"recipient": { "telegram_chat_id": "932004505" },
"content": {
"subject": "๐จ Undangan ke organisasi Acme",
"body": "Vincent mengundang Anda bergabung ke <b>Acme</b> sebagai Editor."
},
"actions": [
{ "key": "accept", "label": "Terima" },
{ "key": "decline", "label": "Tolak" }
],
"metadata": { "event": "org_invitation", "invitation_id": "inv_01HXAC55" },
"created_at": "2026-06-04T03:40:00Z"
},
"responses": [
{
"action_key": "accept",
"data": { "invitation_id": "inv_01HXAC55", "role": "editor" },
"at": "2026-06-04T03:46:00Z",
"by": "acc_01HXAB7Q9M2K"
}
]
}
}
# โโโ 404: id tidak ada / di luar scope โโโ
# { "success": false, "message": "notification not found" }
Tandai satu notifikasi sudah dibaca (set read_at = waktu sekarang). Idempotent โ memanggil ulang pada record yang sudah dibaca tetap 200.
{ "success": true, "message": "marked as read" }
# โโโ 404: id tidak ada / di luar scope โโโ
# { "success": false, "message": "notification not found" }
Tandai semua notifikasi caller sudah dibaca. data.updated = jumlah record yang berubah (yang sebelumnya belum dibaca); 0 bila tak ada yang unread. Service principal boleh narrow via ?target-id=.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| target-id | string | optional | Service principal only โ batasi ke satu target. Diabaikan untuk human. |
{ "success": true, "data": { "updated": 7 } }
# โโโ Tidak ada yang belum dibaca โโโ
# { "success": true, "data": { "updated": 0 } }
Soft-delete โ record di-set deleted_at sehingga hilang dari List/Get, tapi tidak dihapus dari storage (tetap ada untuk audit). Idempotent.
{ "success": true, "message": "deleted" }
# โโโ 404: id tidak ada / di luar scope โโโ
# { "success": false, "message": "notification not found" }
Preferensi milik caller. data.muted = map kategori โ [channel yang dimatikan]. Map kosong {} berarti tidak ada yang dimute (default opt-out). Human dikunci ke dirinya sendiri; service boleh ?target-id=.
{
"success": true,
"data": {
"muted": {
"order_new": ["fcm", "email"],
"marketing": ["inapp", "fcm", "email"]
}
}
}
# โโโ 503: Mongo belum konek โโโ
# { "success": false, "message": "preference store unavailable (Mongo not connected)" }
Replace seluruh map muted milik caller (bukan merge โ kirim map lengkap). Untuk meng-unmute, hilangkan channel/kategori dari map lalu PUT ulang. Kategori "*" = global mute (berlaku semua kategori). Channel divalidasi (inapp/email/fcm/telegram); channel tak dikenal โ 400. Body strict JSON: field di luar muted ditolak 400 (guard agar typo seperti mutd tidak menghapus semua mute). Kategori yang mute 0 channel di-drop. Body dibatasi 64 KiB; nama kategori maks 128 char; maks 256 kategori.
{
"muted": {
"order_new": ["fcm"],
"marketing": ["inapp", "fcm", "email"]
}
}
{ "success": true, "data": { "muted": { "order_new": ["fcm"], "marketing": ["inapp","fcm","email"] } } }
# โโโ 400: channel tak dikenal โโโ
# { "success": false, "message": "unknown channel \"push\" (valid: inapp, email, fcm, telegram)" }
Katalog apa saja yang bisa di-mute, supaya UI setelan bisa render toggle tanpa hardcode. channels = daftar channel valid (id + label tampilan) โ selalu lengkap & otoritatif dari kode (inapp, email, telegram, fcm); categories = daftar kategori yang dideklarasikan deployment (id + label, dari config); global_mute_key = kunci global mute ("*"). Dijamin superset-safe: apa pun yang muncul di sini pasti diterima PUT /api/v1/preferences. Tidak butuh Mongo (selalu jawab, tidak 503).
{
"success": true,
"data": {
"channels": [
{ "id": "inapp", "label": "In-App" },
{ "id": "email", "label": "Email" },
{ "id": "telegram", "label": "Telegram" },
{ "id": "fcm", "label": "Push" }
],
"categories": [
{ "id": "order_new", "label": "Pesanan Baru" },
{ "id": "member_invited", "label": "Undangan Anggota" }
],
"global_mute_key": "*"
}
}
200 jika proses hidup. Tidak mencerminkan status dependency. /live identik.
{ "status": "healthy", "timestamp": 1780540937 }
Cek per-dependency. Consumer RabbitMQ down โ 503 not_ready (fungsi inti). MongoDB down โ dilaporkan "down" tapi ready tetap 200 (by design: kirim notifikasi tetap jalan tanpa history/inbox).
# 200 OK โ semua dependency sehat
{ "status": "ready", "checks": { "consumer": "up", "mongodb": "up" }, "timestamp": 1780540937 }
# โโโ 503 Service Unavailable โ consumer RabbitMQ putus (instance tidak siap) โโโ
# {
# "status": "not_ready",
# "checks": { "consumer": "down", "mongodb": "up" },
# "timestamp": 1780540940
# }
# โโโ 200 OK โ Mongo down tapi consumer up: tetap ready (history/inbox degraded, kirim tetap jalan) โโโ
# {
# "status": "ready",
# "checks": { "consumer": "up", "mongodb": "down" },
# "timestamp": 1780540941
# }
๐ Kirim Notifikasi (untuk Service Producer)
Cara service lain mengirim notifikasi lewat service ini. Satu jalur ingest: RabbitMQ (publish event JSON; consumer kita yang memproses + persist + fan-out).
Titik kirim:
| Broker | amqp://guest:guest@localhost:5672/ (internal, satu server) |
| Exchange | notification.requests (type: topic) |
| Routing key | <apa-saja>.send (mis. notification.send) โ cocok binding *.send |
โ ๏ธ 3 Aturan Emas (penyebab utama notif "tidak muncul")
1. target_id = account-UUID penerima โ nilai yang SAMA dengan sub di JWT penerima. Bukan id internal / email. Kalau tak cocok, notif tidak muncul di inbox user. 2. id unik per penerima โ untuk fan-out (kirim ke banyak member), sertakan account-id di id (mis. invite:ord789:<uuid>). Kalau id sama, hanya satu orang yang dapat record (idempotensi (platform,id)). 3. platform = X-Platform โ harus persis sama dengan yang dipakai frontend saat membaca inbox.
Selebihnya: save default true (tersimpan), provider_type tak wajib bila pakai channels[], category bebas. Untuk in-app only, cukup hilangkan channels[]. Untuk multi-channel, isi channels[] (email/telegram/fcm).
Step 1: Publish event ke RabbitMQ
Satu message JSON per notifikasi (per penerima untuk fan-out).
Step 2: (Fan-out) ulangi per penerima
Kirim banyak ke beberapa member โ loop, satu event per orang dengan target_id = account-UUID masing-masing DAN id unik per orang (mis. tempelkan account-id ke id). Tiap orang dapat record + read/unread + aksi (Terima/Tolak) sendiri.
Step 3: Penerima membaca inbox
Frontend penerima memanggil REST API dengan JWT-nya sendiri:
๐ Push Notification (FCM)
Kirim push ke mobile/web lewat Firebase Cloud Messaging. Muncul walau aplikasi ditutup penuh (ala WhatsApp) karena yang menampilkan adalah OS, bukan aplikasinya โ service mengirim payload notification + priority high.
Channel types yang didukung (recipient field per type):
| type | recipient field wajib | catatan |
|---|---|---|
inapp | โ | otomatis, selalu ada (record di /api/v1/notifications) |
email | email | SMTP, DKIM-signed |
telegram | telegram_chat_id | Bot API |
fcm | fcm_token | push mobile/web (Firebase) |
Mapping content โ FCM: content.subject โ judul notifikasi, content.body โ isi. metadata (string) diteruskan sebagai FCM data agar app bisa baca saat di-tap.
Setup server (sekali): set FCM_CREDENTIALS_FILE (service account JSON dari Firebase Console โ Project settings โ Service accounts โ Generate new private key) + FCM_PROJECT_ID. Untuk iOS, upload APNs key (.p8) di Project settings โ Cloud Messaging.
Beberapa Firebase project: taruh satu file spec per project di PROVIDERS_DIR (mis. fcm_wacca.json, fcm_shop.json), lalu producer pilih lewat provider_name (mis. "provider_name": "fcm_shop"). Satu project boleh berisi banyak app (Android/iOS/Web) โ itu cukup satu provider.
Step 1: App ambil device token
Aplikasi mobile/web mengambil registration token via Firebase SDK (getToken()), lalu mengirimkannya ke backend Anda untuk disimpan per-user. Token bisa berubah โ perbarui saat SDK memberi token baru.
Step 2: Publish event dengan channel fcm
Publish ke exchange notification.requests (routing key *.send).
Step 3: Cek hasil di inbox
GET /api/v1/notifications/{id} โ channel fcm akan sent bila terkirim, atau failed dengan error. Channel gagal di-retry otomatis sampai batas attempt.
Membaca error FCM (diagnosa cepat):
- 400
INVALID_ARGUMENT/ "registration token is not a valid FCM registration token"
โ pipeline & auth FCM SEHAT; hanya token device yang salah/kedaluwarsa/palsu. Aksi: app getToken() ulang & re-register; jangan kirim token contoh (mis. REPLACE_WITH_REAL_FCM_TOKEN).
- 401 / 403 /
UNAUTHENTICATEDโ masalah kredensial server (service account
atau project id). Aksi (ops): cek FCM_CREDENTIALS_FILE + FCM_PROJECT_ID.
- 404
UNREGISTEREDโ token pernah valid tapi sudah dicabut (app di-uninstall /
token expired) โ hapus token itu dari store.
๐ก notification.events.queue
Primary inbound queue. Publish ke exchange notification.requests dengan routing key <provider>.send (binding *.send). Consumer di-supervisi: gagal connect saat startup atau broker drop โ reconnect otomatis tiap reconnect_delay (5s), eskalasi log ERROR tiap 10 attempt.
amqpnotification.events.queue (bound to exchange `notification.requests`, key `*.send`)subscribe Notification service consumes & persists events
Store-then-send: payload dipersist verbatim ke inbox Mongo dulu (kunci idempotensi unik (platform, id)), lalu dikirim via provider. Redelivery atas record berstatus sent di-ack tanpa kirim ulang. Send gagal tapi tercatat di inbox โ ack (failure queryable via REST, tidak redelivery-loop); gagal persist โ nack + requeue.
Idempotensi: publish ulang id yang sama = UPSERT, bukan reject
Duplikat tidak ditolak โ perilakunya per (platform, id):
- Tidak ada kirim ganda. Channel delivery (push/email/telegram)
dibekukan saat insert pertama; publish ulang id yang sama TIDAK memicu push/email ulang, dan status baca user tetap terjaga.
payloaddi-update in-place dari publish terbaru (judul, body,
actions, metadata). Ini fitur: dipakai untuk meng-update notif yang sudah ada di inbox tanpa notifikasi baru โ mis. men-settle notif interaktif (drop tombol Accept/Reject setelah di-accept).
Panduan desain event id:
- Tepat satu notifikasi seumur hidup per entitas โ
id stabil, mis. invite:<invitation_id> atau todo-reminder:<todo_id>:<account_id>.
- Setiap kejadian harus notify lagi (reminder berulang/harian,
status order berubah-ubah) โ sertakan komponen occurrence di id, mis. todo-reminder:<todo_id>:<account_id>:<tanggal/ts>. โ ๏ธ Tanpa ini, occurrence ke-2 dst HANYA meng-update record lama โ user TIDAK menerima push baru.
- Fan-out ke banyak penerima โ id WAJIB unik per penerima
(sertakan account-id), karena (platform,id) adalah satu record.
Kategori: string bebas, tidak ada whitelist
category tidak divalidasi terhadap daftar apa pun โ kategori baru (mis. health-alert) langsung dikenali, dipersist, tampil di inbox, dan bisa difilter, tanpa perubahan/deploy di notification-service. Konsekuensinya:
- Preferensi user otomatis berlaku per kategori: user bisa mute
channel apa pun (termasuk inapp) untuk kategori kalian โ lihat section Notification Preferences API. Kategori yang kritis dan tidak boleh di-mute โ set "mandatory": true di event.
- Pakai slug konsisten (kebab/snake-case) โ kategori juga jadi kunci
preferensi user, jadi ganti nama kategori = preferensi lama user tidak ikut.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Event ID โ kunci idempotensi (platform,id). Publish ulang id sama = UPSERT (payload di-update in-place, TIDAK ada push/email ulang) โ lihat panduan desain id di atas. โ ๏ธ Untuk fan-out per-user, WAJIB unik per penerima (sertakan account-id); untuk reminder berulang, WAJIB sertakan komponen occurrence (tanggal/ts). |
platform |
string | yes | Platform tujuan (mis. wacca) โ โ ๏ธ harus SAMA PERSIS dengan header X-Platform yang dipakai frontend untuk membaca. Bagian kunci idempotensi. |
target_id |
string | no | โ ๏ธ PALING SERING SALAH: harus = sub (account-UUID) di JWT penerima โ BUKAN id internal/email. Tanpa ini cocok, notif TIDAK akan muncul di inbox user. Untuk org, isi org_id (+ target_type=org). Untuk grup, isi group_id (+ target_type=group + org_id) โ lihat section Group Inbox. |
target_type |
string | no | user | org | group | โฆ (group = terlihat semua member roster, shared read-state โ lihat section Group Inbox) |
category |
string | no | String bebas, TANPA whitelist โ kategori baru langsung dikenali tanpa deploy. Jadi kunci filter inbox DAN kunci preferensi mute user (pakai slug konsisten, jangan ganti-ganti nama). |
mandatory |
bool | no | default false โ true untuk BYPASS preferensi user (notif kritis: security/akun/health). Notif mandatory tidak bisa di-mute; selain itu user bisa mematikan channel per kategori. |
org_id |
string | no | |
save |
bool | no | default true โ false untuk skip persist (fire-and-forget) |
source_service |
string | no | Nama service publisher |
title |
string | no | Judul default โ dipakai channel yang tidak meng-override content (lihat channels[]) |
body |
string | no | Body default โ fallback untuk channel tanpa content override |
channels |
array<object> | no | MULTI-CHANNEL: fan-out satu notifikasi ke beberapa tujuan. Tiap entry WAJIB punya type & recipient; content opsional (fallback ke title/body). Bentuk: { type, provider_name?, recipient, content? }. Jika diisi, provider_type/recipient/content top-level diabaikan. |
provider_type |
string | no | SINGLE-CHANNEL (legacy): telegram | email | fcm (aktif) | whatsapp | http (planned). WAJIB jika channels[] tidak dipakai. |
provider_name |
string | no | SINGLE-CHANNEL: mis. telegram_bot, smtp_email. |
recipient |
object | no | SINGLE-CHANNEL: {email, name, telegram_chat_id, whatsapp_number, fcm_token}. WAJIB jika channels[] tidak dipakai; untuk multi-channel ada di tiap entry channels[]. |
content |
object | no | SINGLE-CHANNEL: {subject, body}. Untuk multi-channel, ini default opsional; tiap channel boleh override. |
metadata |
map<string,string> | no | |
created_at |
string | no | ISO 8601 |
Example
# โโโ Single-channel (legacy, tetap didukung) โโโ
{
"id": "evt-invite-1780000000",
"platform": "todo-app",
"target_id": "acc_01HX...",
"category": "invitation",
"recipient": { "telegram_chat_id": "932004505" },
"content": { "subject": "๐จ Undangan", "body": "Anda diundang ke org X" },
"provider_type": "telegram",
"provider_name": "telegram_bot",
"created_at": "2026-06-04T02:00:00Z"
}
# โโโ Multi-channel: SATU notifikasi โ email + telegram (+ in-app) โโโ
# Satu record muncul di /api/v1/notifications dengan channels[] per-status.
# Channel `inapp` otomatis (tak perlu ditulis); email/telegram dikirim
# eksternal. Tiap channel boleh override content; yang tidak pakai
# title/body default.
{
"id": "evt-welcome-1780000001",
"platform": "todo-app",
"target_id": "acc_01HX...",
"category": "onboarding",
"title": "Selamat datang ๐",
"body": "Akun kamu sudah aktif.",
"channels": [
{ "type": "email", "provider_name": "smtp_email",
"recipient": { "email": "rian@example.com", "name": "Rian" },
"content": { "subject": "Selamat datang di Todo App!", "body": "Halo Rian, ... (versi panjang)" } },
{ "type": "telegram", "provider_name": "telegram_bot",
"recipient": { "telegram_chat_id": "932004505" },
"content": { "body": "โ
Akun kamu aktif." } },
{ "type": "fcm", "provider_name": "fcm",
"recipient": { "fcm_token": "fGc9...device-token...xZ2" },
"content": { "subject": "Selamat datang ๐", "body": "Akun kamu aktif โ tap untuk mulai." } }
],
"created_at": "2026-06-04T02:00:00Z"
}
๐ก Group Inbox (notifikasi ke grup)
Notifikasi bisa ditujukan ke grup, bukan satu akun: kirim record biasa tapi dengan target_type: "group" dan target_id: "<group_id>" (+ org_id). Record itu terlihat oleh semua member grup saat ini dengan satu read-state bersama (1 member baca โ tertandai terbaca untuk semua).
Keanggotaan dievaluasi live โ tidak ada snapshot per-notifikasi. Member yang ditambah hari ini langsung melihat notifikasi grup yang lama; member yang dikeluarkan langsung kehilangan akses. Roster dikelola lewat event RabbitMQ terpisah (di bawah). Fitur ini opsional & non-breaking: tanpa membership queue / tanpa record target_type=group, perilaku inbox sama persis seperti sebelumnya.
Mengelola roster grup (exchange notification.membership)
Publish ke exchange notification.membership (topic) โ service membuat exchange ini otomatis. Op ditentukan routing key, body JSON {platform, org_id, group_id, account_ids}:
group.addโ tambah anggota (idempotent,$addToSet).group.removeโ keluarkan anggota (idempotent).group.replaceโ set roster persis =account_ids(rekonsiliasi;
list kosong = kosongkan grup).
โ ๏ธ Kontrak producer (wajib dibaca):
- Record grup HARUS punya channel
inapp(default sudah otomatis
ditambahkan). Tanpa inapp, record tidak akan muncul di inbox semua member (list difilter channels.type:inapp), dan tak bisa ditampilkan.
- Event roster at-least-once & tak terurut.
add/removeidempotent
tapi urutan antar-event tak dijamin โ kirim group.replace periodik sebagai sumber kebenaran (self-heal drift). Jangan andalkan urutan add/remove.
- Maks 10.000
account_idsper event โ event lebih besar di-DROP
(tidak diproses). Pecah roster besar jadi beberapa event / pakai replace.
account_ids= account-UUID (subJWT) tiap member; id kosong ditolak.
Cara membaca (sisi pembaca)
Tetap GET /api/v1/notifications biasa โ service otomatis melebarkan inbox caller ke grup yang ia ikuti (di org token, atau ?org-id=<id> untuk user multi-org). Hanya untuk principal human; service principal tidak dilebarkan.
Batasan yang perlu disadari (by design):
- Notifikasi grup mem-bypass preferensi user โ member **tidak bisa
mute** notifikasi grup (termasuk global-mute "*").
- Member tidak bisa menghapus record grup (DELETE โ 404); read-state
dibagi, bukan per-member.
๐ Credentials
Konfigurasi credentials untuk testing API
Cara Menggunakan
๐ Authentication
Methods and permissions for API access
Authentication Methods
Bearer JWT
Header: Authorization: Bearer <access_token>
Source: account-service (https://account.ikavia.com)
Access token RS256 yang diterbitkan account-service. Notification service memverifikasi signature dengan public key dari GET <account>/api/v1/auth/public-key (di-cache, TTL 15m) dan claim iss=account-service. Fail-closed: tanpa konfigurasi auth, semua route protected ditolak.
Note: Pilih platform via header X-Platform: <platform> (atau ?platform=).
Token dengan satu platform grant otomatis ter-resolve; token wildcard
(*:*) atau multi-platform wajib menyebut platform eksplisit.
> โ๏ธ Sementara (dev): enforcement grant platform sedang DILONGGARKAN
> (AUTH_SKIP_PLATFORM_CHECK=true) โ token valid apa pun boleh memilih
> platform mana saja via X-Platform, tanpa harus punya grant platform:action.
> Authentication (JWT valid) tetap wajib. Akan dikembalikan ke enforcement
> penuh nanti.
Permissions
| Permission | Description |
|---|