{"info":{"title":"Notification Service","version":"2.0.0","description":"Centralized notification service untuk ekosistem ikavia.com. Menerima event\nvia RabbitMQ, mengirim notifikasi multi-channel (Telegram, Email/SMTP, \u0026\nFCM push aktif; whatsapp/webhook planned), dan menyimpan setiap notifikasi sebagai\n**inbox record** yang bisa di-query per-platform via REST API.\n\n**Tech**: Go 1.26, MongoDB (inbox + history), RabbitMQ (AMQP consumer dengan\nauto-reconnect supervisor). **Deploy**: PM2, reverse-proxy nginx + Let's\nEncrypt di `notification.ikavia.com`.\n\n\u003e gRPC service (`proto/notification.proto`, `proto/queue.proto`) terdefinisi\n\u003e di repo tapi **belum di-serve** pada deployment saat ini — integrasi\n\u003e gunakan AMQP (kirim) dan REST (baca inbox).\n","base_url":"https://notification.ikavia.com","base_urls":[{"label":"Production","url":"https://notification.ikavia.com","default":true},{"label":"Local (server-side)","url":"http://127.0.0.1:13080"}],"overview_cards":[{"icon":"📬","title":"Async ingest (RabbitMQ)","description":"Primary integration path untuk MENGIRIM notifikasi","content":"Service lain publish event ke exchange `notification.requests`\n(routing key `*.send`). Notification service consume dari\n`notification.events.queue`, **persist dulu ke inbox (store-then-send)**,\nlalu **fan-out ke semua channel** notifikasi (in-app + email + telegram).\nIdempotent pada `(platform, id)`; channel gagal di-retry otomatis.\n"},{"icon":"📤","title":"Multi-channel fan-out","description":"Satu notifikasi → banyak tujuan","content":"Satu event dengan `channels[]` terkirim ke beberapa channel sekaligus,\nmasing dengan recipient \u0026 content sendiri (default `title`/`body` +\noverride per-channel). Satu record di /api/v1/notifications dengan status\nper-channel; `inapp` selalu ada (record ini = notifikasi in-app).\n"},{"icon":"📥","title":"Inbox REST API","description":"Baca/kelola notifikasi tersimpan per platform","content":"`GET /api/v1/notifications` (list + filter, dengan `channels[]` per-status),\nmark-read, soft-delete. Auth via **JWT RS256 dari account-service**\n(fail-closed). Scoping per-platform via header `X-Platform`. Human\nprincipal hanya melihat inbox miliknya; service melihat platform penuh.\n"},{"icon":"🩺","title":"Health probes","description":"Liveness vs readiness yang jujur","content":"`/health` \u0026 `/live` = liveness murah (proses hidup). `/ready` =\ncek dependency nyata: RabbitMQ consumer (gagal → 503) dan MongoDB\n(degraded, tidak menggagalkan ready).\n"}]},"authentication":{"methods":[{"type":"Bearer JWT","header":"Authorization","format":"Bearer \u003caccess_token\u003e","source":"account-service (https://account.ikavia.com)","description":"Access token RS256 yang diterbitkan account-service. Notification\nservice memverifikasi signature dengan public key dari\n`GET \u003caccount\u003e/api/v1/auth/public-key` (di-cache, TTL 15m) dan\nclaim `iss=account-service`. **Fail-closed**: tanpa konfigurasi auth,\nsemua route protected ditolak.\n","note":"Pilih platform via header `X-Platform: \u003cplatform\u003e` (atau `?platform=`).\nToken dengan satu platform grant otomatis ter-resolve; token wildcard\n(`*:*`) atau multi-platform **wajib** menyebut platform eksplisit.\n\n\u003e ⚙️ **Sementara (dev):** enforcement grant platform sedang DILONGGARKAN\n\u003e (`AUTH_SKIP_PLATFORM_CHECK=true`) — token valid apa pun boleh memilih\n\u003e platform mana saja via `X-Platform`, tanpa harus punya grant `platform:action`.\n\u003e Authentication (JWT valid) tetap wajib. Akan dikembalikan ke enforcement\n\u003e penuh nanti.\n","token_contains":["sub","iss","org_id","permissions","principal_type","token_type"]}]},"flow_overview":{"methods":[{"type":"Bearer JWT","steps":[{"title":"Login / service grant di account-service","detail":"Dapatkan access token RS256 (human login atau service-to-service grant)."},{"title":"Sertakan token + platform di tiap request","detail":"Authorization: Bearer \u003ctoken\u003e dan X-Platform: \u003cplatform\u003e (jika token multi-platform/wildcard)."},{"title":"Akses inbox","detail":"GET /api/v1/notifications mengembalikan inbox sesuai scope: human → miliknya sendiri (target_id = sub), service → seluruh platform."},{"title":"Tracing: X-Request-ID","detail":"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=\u003cid\u003e METHOD path -\u003e status`), jadi tim bisa lacak request spesifik dari log."}]}]},"sections":[{"id":"inbox","title":"Inbox API","description":"Notifikasi yang masuk via RabbitMQ dipersist sebagai inbox record\n(payload producer disimpan verbatim di field `payload`). Endpoint ini\nuntuk membaca dan mengelola record tersebut. Semua endpoint butuh\nBearer JWT; scoping platform via `X-Platform`.\n","endpoints":[{"name":"List notifications","method":"GET","path":"/api/v1/notifications","auth":"Bearer JWT","description":"Satu halaman inbox milik caller, terbaru lebih dulu (urut `received_at`\nmenurun). Human principal otomatis dikunci ke `target_id` = subject\ntoken; service principal melihat seluruh platform dan boleh narrow via\n`target-id`.\n\n**Bentuk response.** `data` adalah array record inbox; tiap item selalu\nmemuat field spine yang ter-index (`id`, `platform`, `target_id`,\n`target_type`, `category`, `org_id`, `status`, `read`, `read_at`,\n`received_at`, `updated_at`), array **`channels`** (status pengiriman\nper-channel), `payload` (pesan asli producer, verbatim), dan\n`responses` (jejak aksi atas notifikasi, mis. accept/decline). `meta`\nmembawa `total` (jumlah seluruh record yang cocok filter, bukan cuma\nhalaman ini), `limit`, dan `offset` untuk pagination.\n\n**Multi-channel.** Satu notifikasi (satu record di sini) bisa terkirim\nke beberapa channel sekaligus — `inapp`, `email`, `telegram` — masing\ndengan recipient \u0026 content sendiri. `channels[]` menampilkan outcome\ntiap channel: `{ type, status, attempts, error?, sent_at? }`. Channel\n`inapp` selalu ada dan otomatis `sent` (record ini sendiri = notifikasi\nin-app). Channel eksternal yang gagal di-retry otomatis sampai batas\n(`NOTIFICATION_RETRY_MAX_ATTEMPTS`, default 3).\n\n**Catatan field:**\n- `status` (agregat seluruh channel): `received` (belum ada channel\n  yang dikirim) · `sent` (semua channel terkirim) · `partial` (sebagian\n  terkirim, sebagian gagal — mis. in-app \u0026 email sukses tapi telegram\n  gagal) · `failed` (semua channel eksternal gagal).\n- `channels[].status`: `pending` · `sending` (sedang dikirim) · `sent`\n  · `failed`. `attempts` = jumlah percobaan; `error` berisi alasan\n  kegagalan terakhir.\n- `read` = `true` bila `read_at` non-null; keduanya konsisten.\n- `payload` bentuknya bebas per producer/provider — jangan asumsikan\n  skema tetap; baca by-key.\n\n**Contoh request:**\n\n```bash\n# Halaman pertama inbox milik user (human token), 20 terbaru\ncurl -s https://notification.ikavia.com/api/v1/notifications \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\" \\\n  -H \"X-Platform: todo-app\"\n\n# Hanya yang belum dibaca, kategori invitation, 10 per halaman\ncurl -s \"https://notification.ikavia.com/api/v1/notifications?unread-only=true\u0026category=invitation\u0026limit=10\" \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\" \\\n  -H \"X-Platform: todo-app\"\n\n# Service token: inbox satu user tertentu, hanya yang gagal kirim\ncurl -s \"https://notification.ikavia.com/api/v1/notifications?target-id=acc_01HXAB\u0026status=failed\" \\\n  -H \"Authorization: Bearer $SERVICE_TOKEN\" \\\n  -H \"X-Platform: todo-app\"\n```\n","query_params":[{"name":"limit","type":"int","default":"20","description":"Item per halaman. Max 100 (di-clamp)."},{"name":"offset","type":"int","default":"0","description":"Pagination offset (jumlah record dilewati)."},{"name":"unread-only","type":"bool","description":"true → hanya record dengan read_at null."},{"name":"status","type":"string","description":"Filter status agregat: received | sent | partial | failed."},{"name":"category","type":"string","description":"Filter by field category producer (mis. invitation, security)."},{"name":"target-type","type":"string","description":"Filter by target_type (mis. user, org, group)."},{"name":"org-id","type":"string","description":"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."},{"name":"target-id","type":"string","description":"Service principal only — narrow ke satu target. Diabaikan untuk human (selalu dikunci ke subject token)."},{"name":"source-service","type":"string","description":"Filter by payload.source_service (service yang publish)."},{"name":"provider-type","type":"string","description":"Filter by payload.provider_type (telegram | email | …)."}],"example_response":"{\n  \"success\": true,\n  \"data\": [\n    {\n      \"id\": \"evt-pwd-reset-1780531200\",\n      \"platform\": \"todo-app\",\n      \"target_id\": \"acc_01HXAB7Q9M2K\",\n      \"target_type\": \"user\",\n      \"category\": \"security\",\n      \"org_id\": \"org_01HXAA0RT3\",\n      \"status\": \"partial\",\n      \"read\": false,\n      \"read_at\": null,\n      \"received_at\": \"2026-06-04T04:20:00Z\",\n      \"updated_at\": \"2026-06-04T04:20:02Z\",\n      \"channels\": [\n        { \"type\": \"inapp\", \"status\": \"sent\", \"attempts\": 0 },\n        { \"type\": \"email\", \"status\": \"sent\", \"attempts\": 1, \"sent_at\": \"2026-06-04T04:20:01Z\" },\n        { \"type\": \"telegram\", \"status\": \"failed\", \"attempts\": 3, \"error\": \"telegram API error: chat not found\" }\n      ],\n      \"payload\": {\n        \"id\": \"evt-pwd-reset-1780531200\",\n        \"platform\": \"todo-app\",\n        \"target_id\": \"acc_01HXAB7Q9M2K\",\n        \"source_service\": \"account-service\",\n        \"title\": \"Keamanan akun\",\n        \"body\": \"Permintaan reset kata sandi diterima.\",\n        \"channels\": [\n          { \"type\": \"email\", \"provider_name\": \"smtp_email\",\n            \"recipient\": { \"email\": \"rian@example.com\", \"name\": \"Rian\" },\n            \"content\": { \"subject\": \"Reset kata sandi Anda\", \"body\": \"Klik tautan berikut. Berlaku 15 menit.\" } },\n          { \"type\": \"telegram\", \"provider_name\": \"telegram_bot\",\n            \"recipient\": { \"telegram_chat_id\": \"932004505\" },\n            \"content\": { \"body\": \"🔐 Permintaan reset kata sandi diterima.\" } }\n        ],\n        \"metadata\": { \"event\": \"password_reset_requested\" },\n        \"created_at\": \"2026-06-04T04:20:00Z\"\n      },\n      \"responses\": []\n    },\n    {\n      \"id\": \"evt-invite-1780528800\",\n      \"platform\": \"todo-app\",\n      \"target_id\": \"acc_01HXAB7Q9M2K\",\n      \"target_type\": \"user\",\n      \"category\": \"invitation\",\n      \"org_id\": \"org_01HXAA0RT3\",\n      \"status\": \"sent\",\n      \"read\": true,\n      \"read_at\": \"2026-06-04T03:45:10Z\",\n      \"received_at\": \"2026-06-04T03:40:00Z\",\n      \"updated_at\": \"2026-06-04T03:46:00Z\",\n      \"channels\": [\n        { \"type\": \"inapp\", \"status\": \"sent\", \"attempts\": 0 },\n        { \"type\": \"telegram\", \"status\": \"sent\", \"attempts\": 1, \"sent_at\": \"2026-06-04T03:40:01Z\" }\n      ],\n      \"payload\": {\n        \"id\": \"evt-invite-1780528800\",\n        \"platform\": \"todo-app\",\n        \"target_id\": \"acc_01HXAB7Q9M2K\",\n        \"source_service\": \"account-service\",\n        \"provider_type\": \"telegram\",\n        \"provider_name\": \"telegram_bot\",\n        \"recipient\": { \"telegram_chat_id\": \"932004505\" },\n        \"content\": {\n          \"subject\": \"📨 Undangan ke organisasi Acme\",\n          \"body\": \"Vincent mengundang Anda bergabung ke \u003cb\u003eAcme\u003c/b\u003e sebagai Editor.\"\n        },\n        \"actions\": [\n          { \"key\": \"accept\", \"label\": \"Terima\" },\n          { \"key\": \"decline\", \"label\": \"Tolak\" }\n        ],\n        \"metadata\": { \"event\": \"org_invitation\", \"invitation_id\": \"inv_01HXAC55\" },\n        \"created_at\": \"2026-06-04T03:40:00Z\"\n      },\n      \"responses\": [\n        {\n          \"action_key\": \"accept\",\n          \"data\": { \"invitation_id\": \"inv_01HXAC55\", \"role\": \"editor\" },\n          \"at\": \"2026-06-04T03:46:00Z\",\n          \"by\": \"acc_01HXAB7Q9M2K\"\n        }\n      ]\n    },\n    {\n      \"id\": \"evt-receipt-1780524000\",\n      \"platform\": \"todo-app\",\n      \"target_id\": \"acc_01HXAB7Q9M2K\",\n      \"target_type\": \"user\",\n      \"category\": \"billing\",\n      \"org_id\": \"org_01HXAA0RT3\",\n      \"status\": \"failed\",\n      \"read\": false,\n      \"read_at\": null,\n      \"received_at\": \"2026-06-04T02:20:00Z\",\n      \"updated_at\": \"2026-06-04T02:20:01Z\",\n      \"channels\": [\n        { \"type\": \"inapp\", \"status\": \"sent\", \"attempts\": 0 },\n        { \"type\": \"email\", \"status\": \"failed\", \"attempts\": 3, \"error\": \"provider smtp_email failed to send: dial tcp mail.ikavia.com:587: i/o timeout\" }\n      ],\n      \"payload\": {\n        \"id\": \"evt-receipt-1780524000\",\n        \"platform\": \"todo-app\",\n        \"target_id\": \"acc_01HXAB7Q9M2K\",\n        \"source_service\": \"billing-service\",\n        \"provider_type\": \"email\",\n        \"provider_name\": \"smtp_email\",\n        \"recipient\": { \"email\": \"rian@example.com\" },\n        \"content\": { \"subject\": \"Struk pembayaran #INV-2026-0412\", \"body\": \"Terlampir struk Anda.\" },\n        \"created_at\": \"2026-06-04T02:20:00Z\"\n      },\n      \"responses\": []\n    }\n  ],\n  \"meta\": { \"total\": 137, \"limit\": 20, \"offset\": 0 }\n}\n\n# ─── Contoh response halaman kosong (mis. ?unread-only=true tapi semua sudah dibaca) ───\n# {\n#   \"success\": true,\n#   \"data\": [],\n#   \"meta\": { \"total\": 0, \"limit\": 20, \"offset\": 0 }\n# }\n\n# ─── Contoh error: token hilang / invalid (401) ───\n# { \"success\": false, \"message\": \"unauthenticated\" }\n#\n# ─── Contoh error: token wildcard/multi-platform tanpa X-Platform (400) ───\n# { \"success\": false, \"message\": \"wildcard grant; select a platform via the X-Platform header or ?platform=\" }\n"},{"name":"Unread count (badge)","method":"GET","path":"/api/v1/notifications/unread-count","auth":"Bearer JWT","description":"Jumlah notifikasi **belum dibaca** milik caller — untuk badge angka di\napp. Count-only (tidak menarik record, jadi murah \u0026 cocok dipanggil\nsering; di-index khusus agar proporsional ke jumlah unread). Owner-scoped:\nhuman → miliknya sendiri. Karena badge selalu **per-user**, principal\nservice **wajib** menyertakan `?target-id=\u003caccount\u003e` (kalau tidak → `400`,\nmencegah hitung seluruh platform). Hanya menghitung record yang tampil di\ninbox in-app (record yang channel `inapp`-nya dimute user tidak ikut),\nkonsisten dengan `GET /api/v1/notifications`.\n","example_response":"{ \"success\": true, \"data\": { \"unread\": 7 } }\n\n# ─── 400: service principal tanpa target-id ───\n# { \"success\": false, \"message\": \"target-id is required\" }\n"},{"name":"Get notification","method":"GET","path":"/api/v1/notifications/{id}","auth":"Bearer JWT","description":"Detail satu record. Bentuk item identik dengan satu elemen `data[]` pada\nList (spine + `payload` verbatim + `responses`). 404 (`notification not found`) jika id tidak ada\natau di luar scope caller (human hanya boleh id miliknya sendiri).\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"evt-invite-1780528800\",\n    \"platform\": \"todo-app\",\n    \"target_id\": \"acc_01HXAB7Q9M2K\",\n    \"target_type\": \"user\",\n    \"category\": \"invitation\",\n    \"org_id\": \"org_01HXAA0RT3\",\n    \"status\": \"sent\",\n    \"read\": true,\n    \"read_at\": \"2026-06-04T03:45:10Z\",\n    \"received_at\": \"2026-06-04T03:40:00Z\",\n    \"updated_at\": \"2026-06-04T03:46:00Z\",\n    \"channels\": [\n      { \"type\": \"inapp\", \"status\": \"sent\", \"attempts\": 0 },\n      { \"type\": \"telegram\", \"status\": \"sent\", \"attempts\": 1, \"sent_at\": \"2026-06-04T03:40:01Z\" }\n    ],\n    \"payload\": {\n      \"id\": \"evt-invite-1780528800\",\n      \"platform\": \"todo-app\",\n      \"target_id\": \"acc_01HXAB7Q9M2K\",\n      \"source_service\": \"account-service\",\n      \"provider_type\": \"telegram\",\n      \"provider_name\": \"telegram_bot\",\n      \"recipient\": { \"telegram_chat_id\": \"932004505\" },\n      \"content\": {\n        \"subject\": \"📨 Undangan ke organisasi Acme\",\n        \"body\": \"Vincent mengundang Anda bergabung ke \u003cb\u003eAcme\u003c/b\u003e sebagai Editor.\"\n      },\n      \"actions\": [\n        { \"key\": \"accept\", \"label\": \"Terima\" },\n        { \"key\": \"decline\", \"label\": \"Tolak\" }\n      ],\n      \"metadata\": { \"event\": \"org_invitation\", \"invitation_id\": \"inv_01HXAC55\" },\n      \"created_at\": \"2026-06-04T03:40:00Z\"\n    },\n    \"responses\": [\n      {\n        \"action_key\": \"accept\",\n        \"data\": { \"invitation_id\": \"inv_01HXAC55\", \"role\": \"editor\" },\n        \"at\": \"2026-06-04T03:46:00Z\",\n        \"by\": \"acc_01HXAB7Q9M2K\"\n      }\n    ]\n  }\n}\n\n# ─── 404: id tidak ada / di luar scope ───\n# { \"success\": false, \"message\": \"notification not found\" }\n"},{"name":"Mark as read","method":"POST","path":"/api/v1/notifications/{id}/read","auth":"Bearer JWT","description":"Tandai satu notifikasi sudah dibaca (set `read_at` = waktu sekarang).\nIdempotent — memanggil ulang pada record yang sudah dibaca tetap 200.\n","example_response":"{ \"success\": true, \"message\": \"marked as read\" }\n\n# ─── 404: id tidak ada / di luar scope ───\n# { \"success\": false, \"message\": \"notification not found\" }\n"},{"name":"Mark all as read","method":"POST","path":"/api/v1/notifications/read-all","auth":"Bearer JWT","description":"Tandai semua notifikasi caller sudah dibaca. `data.updated` = jumlah\nrecord yang berubah (yang sebelumnya belum dibaca); 0 bila tak ada yang\nunread. Service principal boleh narrow via `?target-id=`.\n","query_params":[{"name":"target-id","type":"string","description":"Service principal only — batasi ke satu target. Diabaikan untuk human."}],"example_response":"{ \"success\": true, \"data\": { \"updated\": 7 } }\n\n# ─── Tidak ada yang belum dibaca ───\n# { \"success\": true, \"data\": { \"updated\": 0 } }\n"},{"name":"Delete notification","method":"DELETE","path":"/api/v1/notifications/{id}","auth":"Bearer JWT","description":"Soft-delete — record di-set `deleted_at` sehingga hilang dari List/Get,\ntapi tidak dihapus dari storage (tetap ada untuk audit). Idempotent.\n","example_response":"{ \"success\": true, \"message\": \"deleted\" }\n\n# ─── 404: id tidak ada / di luar scope ───\n# { \"success\": false, \"message\": \"notification not found\" }\n"}]},{"id":"preferences","title":"Notification Preferences API","description":"Preferensi notifikasi per-user: tiap user bisa mematikan (mute) channel\ntertentu untuk kategori tertentu. Model **opt-out** — defaultnya semua ON;\nuser hanya menyimpan yang ingin dimatikan. Di-scope per `(platform, user)`,\nBearer JWT, owner-scoped (human → preferensinya sendiri; service principal\nboleh `?target-id=`).\n\n**Cara kerja saat kirim:** sebelum notifikasi dipersist \u0026 dikirim,\nnotification-service membuang channel yang di-mute user untuk\n`category`-nya. Kalau **semua** channel termute → notifikasi disuppress\ntotal (tidak ada record, tidak ada kirim). Channel `inapp` yang dimute =\nrecord tidak muncul di list `GET /api/v1/notifications` (channel lain tetap\nterkirim bila ON).\n\n**Bypass:** notifikasi `mandatory` (producer set `\"mandatory\": true` di\nevent — untuk security/akun/critical) mengabaikan preferensi sepenuhnya.\nNotifikasi ber-`target_type` ≠ `user` (mis. `org`) juga tidak difilter.\nBila Mongo down, fail-open (kirim semua) — preferensi tidak pernah men-drop\nnotifikasi karena gagal dibaca (mute tidak ditegakkan selama Mongo down).\n\n**Berlaku sejak diterima (frozen-at-receive):** preferensi dievaluasi saat\nnotifikasi diterima. Mute yang di-set **setelah** sebuah notifikasi sudah\nditerima tidak retroaktif menghentikan retry channel yang sudah tersimpan\nuntuk notifikasi itu — mute berlaku untuk notifikasi berikutnya.\n\n**Channel valid:** `inapp`, `email`, `fcm`, `telegram`.\n\n**Global mute (`\"*\"`):** kategori khusus `\"*\"` me-mute channel untuk\n**semua kategori — termasuk kategori baru di masa depan**. Contoh:\n`{\"muted\": {\"*\": [\"email\"]}}` = \"jangan pernah email saya\";\n`{\"muted\": {\"*\": [\"fcm\"], \"marketing\": [\"inapp\"]}}` = matikan semua push\n+ sembunyikan marketing dari inbox. Mute efektif = gabungan list kategori\n+ list `\"*\"`. Notifikasi `mandatory` tetap bypass.\n","endpoints":[{"name":"Get preferences","method":"GET","path":"/api/v1/preferences","auth":"Bearer JWT","description":"Preferensi milik caller. `data.muted` = map `kategori → [channel yang\ndimatikan]`. Map kosong `{}` berarti tidak ada yang dimute (default\nopt-out). Human dikunci ke dirinya sendiri; service boleh `?target-id=`.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"muted\": {\n      \"order_new\": [\"fcm\", \"email\"],\n      \"marketing\": [\"inapp\", \"fcm\", \"email\"]\n    }\n  }\n}\n\n# ─── 503: Mongo belum konek ───\n# { \"success\": false, \"message\": \"preference store unavailable (Mongo not connected)\" }\n"},{"name":"Set preferences","method":"PUT","path":"/api/v1/preferences","auth":"Bearer JWT","description":"**Replace** seluruh map `muted` milik caller (bukan merge — kirim map\nlengkap). Untuk meng-unmute, hilangkan channel/kategori dari map lalu\nPUT ulang. Kategori `\"*\"` = global mute (berlaku semua kategori).\nChannel divalidasi (`inapp`/`email`/`fcm`/`telegram`); channel\ntak dikenal → **400**. Body **strict JSON**: field di luar `muted`\nditolak 400 (guard agar typo seperti `mutd` tidak menghapus semua mute).\nKategori yang mute 0 channel di-drop. Body dibatasi\n64 KiB; nama kategori maks 128 char; maks 256 kategori.\n","example_body":"{\n  \"muted\": {\n    \"order_new\": [\"fcm\"],\n    \"marketing\": [\"inapp\", \"fcm\", \"email\"]\n  }\n}\n","example_response":"{ \"success\": true, \"data\": { \"muted\": { \"order_new\": [\"fcm\"], \"marketing\": [\"inapp\",\"fcm\",\"email\"] } } }\n\n# ─── 400: channel tak dikenal ───\n# { \"success\": false, \"message\": \"unknown channel \\\"push\\\" (valid: inapp, email, fcm, telegram)\" }\n"},{"name":"Get preference options (katalog)","method":"GET","path":"/api/v1/preferences/options","auth":"Bearer JWT","description":"Katalog **apa saja yang bisa di-mute**, supaya UI setelan bisa render\ntoggle tanpa hardcode. `channels` = daftar channel valid (id + label\ntampilan) — selalu lengkap \u0026 otoritatif dari kode (`inapp`, `email`,\n`telegram`, `fcm`); `categories` = daftar kategori yang dideklarasikan\ndeployment (id + label, dari config); `global_mute_key` = kunci global\nmute (`\"*\"`). Dijamin **superset-safe**: apa pun yang muncul di sini\npasti diterima `PUT /api/v1/preferences`. Tidak butuh Mongo (selalu\njawab, tidak 503).\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"channels\": [\n      { \"id\": \"inapp\", \"label\": \"In-App\" },\n      { \"id\": \"email\", \"label\": \"Email\" },\n      { \"id\": \"telegram\", \"label\": \"Telegram\" },\n      { \"id\": \"fcm\", \"label\": \"Push\" }\n    ],\n    \"categories\": [\n      { \"id\": \"order_new\", \"label\": \"Pesanan Baru\" },\n      { \"id\": \"member_invited\", \"label\": \"Undangan Anggota\" }\n    ],\n    \"global_mute_key\": \"*\"\n  }\n}\n"}]},{"id":"health","title":"Health Probes","description":"Liveness (`/health`, `/live`) sengaja murah dan bebas dependency —\norchestrator tidak boleh membunuh proses yang sekadar menunggu downstream.\nReadiness (`/ready`) menjawab \"bisa kerja sekarang?\" dengan cek nyata.\n","endpoints":[{"name":"Liveness","method":"GET","path":"/health","auth":"none","description":"200 jika proses hidup. Tidak mencerminkan status dependency. `/live` identik.","example_response":"{ \"status\": \"healthy\", \"timestamp\": 1780540937 }\n"},{"name":"Readiness","method":"GET","path":"/ready","auth":"none","description":"Cek per-dependency. Consumer RabbitMQ down → **503 not_ready** (fungsi\ninti). MongoDB down → dilaporkan `\"down\"` tapi ready tetap 200\n(by design: kirim notifikasi tetap jalan tanpa history/inbox).\n","example_response":"# 200 OK — semua dependency sehat\n{ \"status\": \"ready\", \"checks\": { \"consumer\": \"up\", \"mongodb\": \"up\" }, \"timestamp\": 1780540937 }\n\n# ─── 503 Service Unavailable — consumer RabbitMQ putus (instance tidak siap) ───\n# {\n#   \"status\": \"not_ready\",\n#   \"checks\": { \"consumer\": \"down\", \"mongodb\": \"up\" },\n#   \"timestamp\": 1780540940\n# }\n\n# ─── 200 OK — Mongo down tapi consumer up: tetap ready (history/inbox degraded, kirim tetap jalan) ───\n# {\n#   \"status\": \"ready\",\n#   \"checks\": { \"consumer\": \"up\", \"mongodb\": \"down\" },\n#   \"timestamp\": 1780540941\n# }\n"}]}],"guides":[{"id":"producer_quickstart","icon":"🚀","title":"Kirim Notifikasi (untuk Service Producer)","description":"Cara service lain mengirim notifikasi lewat service ini. **Satu jalur ingest:\nRabbitMQ** (publish event JSON; consumer kita yang memproses + persist + fan-out).\n\n**Titik kirim:**\n| | |\n|---|---|\n| Broker | `amqp://guest:guest@localhost:5672/` (internal, satu server) |\n| Exchange | `notification.requests` (type: topic) |\n| Routing key | `\u003capa-saja\u003e.send` (mis. `notification.send`) — cocok binding `*.send` |\n\n## ⚠️ 3 Aturan Emas (penyebab utama notif \"tidak muncul\")\n1. **`target_id` = account-UUID penerima** — nilai yang SAMA dengan `sub` di\n   JWT penerima. **Bukan** id internal / email. Kalau tak cocok, notif tidak\n   muncul di inbox user.\n2. **`id` unik per penerima** — untuk fan-out (kirim ke banyak member), sertakan\n   account-id di `id` (mis. `invite:ord789:\u003cuuid\u003e`). Kalau `id` sama, hanya satu\n   orang yang dapat record (idempotensi `(platform,id)`).\n3. **`platform` = `X-Platform`** — harus persis sama dengan yang dipakai frontend\n   saat membaca inbox.\n\nSelebihnya: `save` default `true` (tersimpan), `provider_type` tak wajib bila\npakai `channels[]`, `category` bebas. Untuk **in-app only**, cukup hilangkan\n`channels[]`. Untuk multi-channel, isi `channels[]` (email/telegram/fcm).\n","flow":[{"step":1,"title":"Publish event ke RabbitMQ","description":"Satu message JSON per notifikasi (per penerima untuk fan-out).","curl_example":"# contoh pakai rabbitmqadmin (atau library AMQP service Anda)\nrabbitmqadmin -u guest -p guest publish \\\n  exchange=notification.requests routing_key=notification.send payload='{\n    \"id\": \"invite:ord789:a2f32c84-639c-4778-8d30-2cc1ba3ca427\",\n    \"platform\": \"wacca\",\n    \"target_id\": \"a2f32c84-639c-4778-8d30-2cc1ba3ca427\",\n    \"target_type\": \"user\",\n    \"category\": \"member-invitation\",\n    \"title\": \"Undangan member\",\n    \"body\": \"Anda diundang bergabung\",\n    \"channels\": [\n      { \"type\": \"email\", \"recipient\": { \"email\": \"user@example.com\" },\n        \"content\": { \"subject\": \"Undangan\", \"body\": \"Detail undangan…\" } },\n      { \"type\": \"fcm\", \"recipient\": { \"fcm_token\": \"\u003cdevice token\u003e\" } }\n    ],\n    \"source_service\": \"wacca-service\"\n  }'\n"},{"step":2,"title":"(Fan-out) ulangi per penerima","description":"Kirim banyak ke beberapa member → loop, satu event per orang dengan\n`target_id` = account-UUID masing-masing DAN `id` unik per orang\n(mis. tempelkan account-id ke `id`). Tiap orang dapat record + read/unread\n+ aksi (Terima/Tolak) sendiri.\n"},{"step":3,"title":"Penerima membaca inbox","description":"Frontend penerima memanggil REST API dengan JWT-nya sendiri:\n","curl_example":"curl https://notification.ikavia.com/api/v1/notifications \\\n  -H \"Authorization: Bearer \u003cJWT penerima\u003e\" \\\n  -H \"X-Platform: wacca\"\n"}]},{"id":"fcm_push","icon":"🔔","title":"Push Notification (FCM)","description":"Kirim push ke mobile/web lewat Firebase Cloud Messaging. Muncul **walau\naplikasi ditutup penuh** (ala WhatsApp) karena yang menampilkan adalah OS,\nbukan aplikasinya — service mengirim payload `notification` + priority high.\n\n**Channel types yang didukung** (recipient field per type):\n\n| type | recipient field wajib | catatan |\n|---|---|---|\n| `inapp` | — | otomatis, selalu ada (record di /api/v1/notifications) |\n| `email` | `email` | SMTP, DKIM-signed |\n| `telegram` | `telegram_chat_id` | Bot API |\n| `fcm` | `fcm_token` | push mobile/web (Firebase) |\n\n**Mapping content → FCM:** `content.subject` → judul notifikasi,\n`content.body` → isi. `metadata` (string) diteruskan sebagai FCM `data`\nagar app bisa baca saat di-tap.\n\n**Setup server (sekali):** set `FCM_CREDENTIALS_FILE` (service account JSON\ndari Firebase Console → Project settings → Service accounts → Generate\nnew private key) + `FCM_PROJECT_ID`. Untuk iOS, upload APNs key (.p8) di\nProject settings → Cloud Messaging.\n\n**Beberapa Firebase project:** taruh satu file spec per project di\n`PROVIDERS_DIR` (mis. `fcm_wacca.json`, `fcm_shop.json`), lalu producer\npilih lewat `provider_name` (mis. `\"provider_name\": \"fcm_shop\"`). Satu\nproject boleh berisi banyak app (Android/iOS/Web) — itu cukup satu provider.\n","flow":[{"step":1,"title":"App ambil device token","description":"Aplikasi mobile/web mengambil registration token via Firebase SDK\n(`getToken()`), lalu mengirimkannya ke backend Anda untuk disimpan\nper-user. Token bisa berubah — perbarui saat SDK memberi token baru.\n"},{"step":2,"title":"Publish event dengan channel fcm","description":"Publish ke exchange `notification.requests` (routing key `*.send`).","curl_example":"rabbitmqadmin -u guest -p guest publish \\\n  exchange=notification.requests routing_key=fcm.send payload='{\n    \"id\": \"evt-promo-1780000002\",\n    \"platform\": \"wacca\",\n    \"target_id\": \"acc_01HX...\",\n    \"category\": \"promo\",\n    \"channels\": [\n      { \"type\": \"fcm\", \"provider_name\": \"fcm\",\n        \"recipient\": { \"fcm_token\": \"\u003cdevice token dari app\u003e\" },\n        \"content\": { \"subject\": \"Diskon 20% 🎉\", \"body\": \"Khusus hari ini, tap untuk lihat.\" } }\n    ]\n  }'\n"},{"step":3,"title":"Cek hasil di inbox","description":"`GET /api/v1/notifications/{id}` → channel `fcm` akan `sent` bila terkirim,\natau `failed` dengan error. Channel gagal di-retry otomatis sampai batas attempt.\n\n**Membaca error FCM (diagnosa cepat):**\n- **400 `INVALID_ARGUMENT` / \"registration token is not a valid FCM registration token\"**\n  → pipeline \u0026 auth FCM **SEHAT**; hanya token device yang salah/kedaluwarsa/palsu.\n  Aksi: app `getToken()` ulang \u0026 re-register; jangan kirim token contoh\n  (mis. `REPLACE_WITH_REAL_FCM_TOKEN`).\n- **401 / 403 / `UNAUTHENTICATED`** → masalah kredensial server (service account\n  atau project id). Aksi (ops): cek `FCM_CREDENTIALS_FILE` + `FCM_PROJECT_ID`.\n- **404 `UNREGISTERED`** → token pernah valid tapi sudah dicabut (app di-uninstall /\n  token expired) → hapus token itu dari store.\n"}]}],"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 \u0026 tercatat `failed` di inbox.","FCM push: channel `{ type: \"fcm\", recipient: { fcm_token: \"\u003cdevice token\u003e\" }, content: { subject?: \u003ctitle\u003e, body: \u003ctext\u003e } }`. 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 \u0026 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 \u0026 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 \u0026 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 \u003e1 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` \u0026 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."],"api_tester_defaults":{"methods":["GET","POST","DELETE"],"auth_modes":[{"name":"Bearer JWT","header":"Authorization"}]},"events":[{"id":"notification-events-queue","title":"notification.events.queue","description":"**Primary inbound queue.** Publish ke exchange `notification.requests`\ndengan routing key `\u003cprovider\u003e.send` (binding `*.send`). Consumer\ndi-supervisi: gagal connect saat startup atau broker drop → reconnect\notomatis tiap `reconnect_delay` (5s), eskalasi log ERROR tiap 10 attempt.\n","protocol":"amqp","address":"notification.events.queue (bound to exchange `notification.requests`, key `*.send`)","operations":[{"type":"subscribe","summary":"Notification service consumes \u0026 persists events","description":"**Store-then-send**: payload dipersist verbatim ke inbox Mongo dulu\n(kunci idempotensi unik `(platform, id)`), lalu dikirim via provider.\nRedelivery atas record berstatus `sent` di-ack tanpa kirim ulang.\nSend gagal tapi tercatat di inbox → ack (failure queryable via REST,\ntidak redelivery-loop); gagal persist → nack + requeue.\n\n## Idempotensi: publish ulang id yang sama = UPSERT, bukan reject\n\nDuplikat **tidak ditolak** — perilakunya per `(platform, id)`:\n\n- **Tidak ada kirim ganda.** Channel delivery (push/email/telegram)\n  dibekukan saat insert pertama; publish ulang id yang sama TIDAK\n  memicu push/email ulang, dan status baca user tetap terjaga.\n- **`payload` di-update in-place** dari publish terbaru (judul, body,\n  `actions`, metadata). Ini fitur: dipakai untuk meng-update notif yang\n  sudah ada di inbox tanpa notifikasi baru — mis. men-settle notif\n  interaktif (drop tombol Accept/Reject setelah di-accept).\n\n**Panduan desain event id:**\n- *Tepat satu notifikasi seumur hidup* per entitas →\n  id stabil, mis. `invite:\u003cinvitation_id\u003e` atau\n  `todo-reminder:\u003ctodo_id\u003e:\u003caccount_id\u003e`.\n- *Setiap kejadian harus notify lagi* (reminder berulang/harian,\n  status order berubah-ubah) → sertakan komponen occurrence di id,\n  mis. `todo-reminder:\u003ctodo_id\u003e:\u003caccount_id\u003e:\u003ctanggal/ts\u003e`.\n  ⚠️ Tanpa ini, occurrence ke-2 dst HANYA meng-update record lama —\n  user TIDAK menerima push baru.\n- *Fan-out ke banyak penerima* → id WAJIB unik per penerima\n  (sertakan account-id), karena `(platform,id)` adalah satu record.\n\n## Kategori: string bebas, tidak ada whitelist\n\n`category` tidak divalidasi terhadap daftar apa pun — kategori baru\n(mis. `health-alert`) langsung dikenali, dipersist, tampil di inbox,\ndan bisa difilter, **tanpa perubahan/deploy di notification-service**.\nKonsekuensinya:\n\n- **Preferensi user otomatis berlaku** per kategori: user bisa mute\n  channel apa pun (termasuk `inapp`) untuk kategori kalian — lihat\n  section *Notification Preferences API*. Kategori yang kritis dan\n  tidak boleh di-mute → set `\"mandatory\": true` di event.\n- Pakai slug konsisten (kebab/snake-case) — kategori juga jadi kunci\n  preferensi user, jadi ganti nama kategori = preferensi lama user\n  tidak ikut.\n","payload":[{"name":"id","type":"string","required":true,"description":"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)."},{"name":"platform","type":"string","required":true,"description":"Platform tujuan (mis. wacca) — ⚠️ harus SAMA PERSIS dengan header `X-Platform` yang dipakai frontend untuk membaca. Bagian kunci idempotensi."},{"name":"target_id","type":"string","description":"⚠️ 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."},{"name":"target_type","type":"string","description":"user | org | group | … (group = terlihat semua member roster, shared read-state — lihat section Group Inbox)"},{"name":"category","type":"string","description":"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)."},{"name":"mandatory","type":"bool","description":"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."},{"name":"org_id","type":"string"},{"name":"save","type":"bool","description":"default true — false untuk skip persist (fire-and-forget)"},{"name":"source_service","type":"string","description":"Nama service publisher"},{"name":"title","type":"string","description":"Judul default — dipakai channel yang tidak meng-override content (lihat channels[])"},{"name":"body","type":"string","description":"Body default — fallback untuk channel tanpa content override"},{"name":"channels","type":"array\u003cobject\u003e","description":"MULTI-CHANNEL: fan-out satu notifikasi ke beberapa tujuan. Tiap entry WAJIB punya type \u0026 recipient; content opsional (fallback ke title/body). Bentuk: { type, provider_name?, recipient, content? }. Jika diisi, provider_type/recipient/content top-level diabaikan."},{"name":"provider_type","type":"string","description":"SINGLE-CHANNEL (legacy): telegram | email | fcm (aktif) | whatsapp | http (planned). WAJIB jika channels[] tidak dipakai."},{"name":"provider_name","type":"string","description":"SINGLE-CHANNEL: mis. telegram_bot, smtp_email."},{"name":"recipient","type":"object","description":"SINGLE-CHANNEL: {email, name, telegram_chat_id, whatsapp_number, fcm_token}. WAJIB jika channels[] tidak dipakai; untuk multi-channel ada di tiap entry channels[]."},{"name":"content","type":"object","description":"SINGLE-CHANNEL: {subject, body}. Untuk multi-channel, ini default opsional; tiap channel boleh override."},{"name":"metadata","type":"map\u003cstring,string\u003e"},{"name":"created_at","type":"string","description":"ISO 8601"}],"example":"# ─── Single-channel (legacy, tetap didukung) ───\n{\n  \"id\": \"evt-invite-1780000000\",\n  \"platform\": \"todo-app\",\n  \"target_id\": \"acc_01HX...\",\n  \"category\": \"invitation\",\n  \"recipient\": { \"telegram_chat_id\": \"932004505\" },\n  \"content\": { \"subject\": \"📨 Undangan\", \"body\": \"Anda diundang ke org X\" },\n  \"provider_type\": \"telegram\",\n  \"provider_name\": \"telegram_bot\",\n  \"created_at\": \"2026-06-04T02:00:00Z\"\n}\n\n# ─── Multi-channel: SATU notifikasi → email + telegram (+ in-app) ───\n# Satu record muncul di /api/v1/notifications dengan channels[] per-status.\n# Channel `inapp` otomatis (tak perlu ditulis); email/telegram dikirim\n# eksternal. Tiap channel boleh override content; yang tidak pakai\n# title/body default.\n{\n  \"id\": \"evt-welcome-1780000001\",\n  \"platform\": \"todo-app\",\n  \"target_id\": \"acc_01HX...\",\n  \"category\": \"onboarding\",\n  \"title\": \"Selamat datang 🎉\",\n  \"body\": \"Akun kamu sudah aktif.\",\n  \"channels\": [\n    { \"type\": \"email\", \"provider_name\": \"smtp_email\",\n      \"recipient\": { \"email\": \"rian@example.com\", \"name\": \"Rian\" },\n      \"content\": { \"subject\": \"Selamat datang di Todo App!\", \"body\": \"Halo Rian, ... (versi panjang)\" } },\n    { \"type\": \"telegram\", \"provider_name\": \"telegram_bot\",\n      \"recipient\": { \"telegram_chat_id\": \"932004505\" },\n      \"content\": { \"body\": \"✅ Akun kamu aktif.\" } },\n    { \"type\": \"fcm\", \"provider_name\": \"fcm\",\n      \"recipient\": { \"fcm_token\": \"fGc9...device-token...xZ2\" },\n      \"content\": { \"subject\": \"Selamat datang 🎉\", \"body\": \"Akun kamu aktif — tap untuk mulai.\" } }\n  ],\n  \"created_at\": \"2026-06-04T02:00:00Z\"\n}\n"}]},{"id":"group-inbox","title":"Group Inbox (notifikasi ke grup)","description":"Notifikasi bisa ditujukan ke **grup**, bukan satu akun: kirim record biasa\ntapi dengan `target_type: \"group\"` dan `target_id: \"\u003cgroup_id\u003e\"` (+ `org_id`).\nRecord itu terlihat oleh **semua member grup saat ini** dengan **satu\nread-state bersama** (1 member baca → tertandai terbaca untuk semua).\n\n**Keanggotaan dievaluasi live** — tidak ada snapshot per-notifikasi. Member\nyang ditambah hari ini langsung melihat notifikasi grup yang lama; member\nyang dikeluarkan langsung kehilangan akses. Roster dikelola lewat event\nRabbitMQ terpisah (di bawah). Fitur ini **opsional \u0026 non-breaking**: tanpa\nmembership queue / tanpa record `target_type=group`, perilaku inbox sama\npersis seperti sebelumnya.\n\n## Mengelola roster grup (exchange `notification.membership`)\nPublish ke exchange **`notification.membership`** (topic) — service membuat\nexchange ini otomatis. **Op ditentukan routing key**, body JSON\n`{platform, org_id, group_id, account_ids}`:\n- `group.add` — tambah anggota (idempotent, `$addToSet`).\n- `group.remove` — keluarkan anggota (idempotent).\n- `group.replace` — set roster **persis** = `account_ids` (rekonsiliasi;\n  list kosong = kosongkan grup).\n\n⚠️ **Kontrak producer (wajib dibaca):**\n- **Record grup HARUS punya channel `inapp`** (default sudah otomatis\n  ditambahkan). Tanpa `inapp`, record tidak akan muncul di inbox **semua**\n  member (list difilter `channels.type:inapp`), dan tak bisa ditampilkan.\n- Event roster **at-least-once \u0026 tak terurut**. `add`/`remove` idempotent\n  tapi urutan antar-event tak dijamin → **kirim `group.replace` periodik**\n  sebagai sumber kebenaran (self-heal drift). Jangan andalkan urutan\n  add/remove.\n- **Maks 10.000 `account_ids` per event** — event lebih besar di-DROP\n  (tidak diproses). Pecah roster besar jadi beberapa event / pakai replace.\n- `account_ids` = account-UUID (`sub` JWT) tiap member; id kosong ditolak.\n\n## Cara membaca (sisi pembaca)\nTetap `GET /api/v1/notifications` biasa — service otomatis melebarkan inbox\ncaller ke grup yang ia ikuti (di org token, atau `?org-id=\u003cid\u003e` untuk user\nmulti-org). Hanya untuk principal human; service principal tidak dilebarkan.\n\n**Batasan yang perlu disadari (by design):**\n- Notifikasi grup **mem-bypass preferensi user** — member **tidak bisa\n  mute** notifikasi grup (termasuk global-mute `\"*\"`).\n- Member **tidak bisa menghapus** record grup (DELETE → 404); read-state\n  dibagi, bukan per-member.\n"}],"theme":{"title":"Notification · Ikavia","logo_icon":"🔔","primary_color":"#E9674D"}}