# Merged spec generated by docs-generator.
# This document is the effective YAML behind the rendered page —
# consolidated from every file in the source directory.
# project: notification
# yaml-language-server: $schema=/docs/../schemas/spec.schema.json

info:
    title: Notification Service
    version: 2.0.0
    description: |
        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).
    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: "\U0001F4EC"
          title: Async ingest (RabbitMQ)
          description: Primary integration path untuk MENGIRIM notifikasi
          content: |
            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.
        - icon: "\U0001F4E4"
          title: Multi-channel fan-out
          description: Satu notifikasi → banyak tujuan
          content: |
            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).
        - icon: "\U0001F4E5"
          title: Inbox REST API
          description: Baca/kelola notifikasi tersimpan per platform
          content: |
            `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.
        - icon: "\U0001FA7A"
          title: Health probes
          description: Liveness vs readiness yang jujur
          content: |
            `/health` & `/live` = liveness murah (proses hidup). `/ready` =
            cek dependency nyata: RabbitMQ consumer (gagal → 503) dan MongoDB
            (degraded, tidak menggagalkan ready).
authentication:
    methods:
        - type: Bearer JWT
          header: Authorization
          format: Bearer <access_token>
          source: account-service (https://account.ikavia.com)
          description: |
            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.
          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 <token> dan X-Platform: <platform> (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=<id> METHOD path -> 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
        (payload producer disimpan verbatim di field `payload`). Endpoint ini
        untuk membaca dan mengelola record tersebut. Semua endpoint butuh
        Bearer JWT; scoping platform via `X-Platform`.
      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`
            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` = `true` bila `read_at` non-null; keduanya konsisten.
            - `payload` bentuknya bebas per producer/provider — jangan asumsikan
              skema tetap; baca by-key.

            **Contoh request:**

            ```bash
            # 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_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\": \"\U0001F510 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\": \"\U0001F4E8 Undangan ke organisasi Acme\",\n          \"body\": \"Vincent mengundang Anda bergabung ke <b>Acme</b> 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
            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`.
          example_response: |
            { "success": true, "data": { "unread": 7 } }

            # ─── 400: service principal tanpa target-id ───
            # { "success": false, "message": "target-id is required" }
        - 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
            List (spine + `payload` verbatim + `responses`). 404 (`notification not found`) jika id tidak ada
            atau di luar scope caller (human hanya boleh id miliknya sendiri).
          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\": \"\U0001F4E8 Undangan ke organisasi Acme\",\n        \"body\": \"Vincent mengundang Anda bergabung ke <b>Acme</b> 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).
            Idempotent — memanggil ulang pada record yang sudah dibaca tetap 200.
          example_response: |
            { "success": true, "message": "marked as read" }

            # ─── 404: id tidak ada / di luar scope ───
            # { "success": false, "message": "notification not found" }
        - 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
            record yang berubah (yang sebelumnya belum dibaca); 0 bila tak ada yang
            unread. Service principal boleh narrow via `?target-id=`.
          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 } }

            # ─── Tidak ada yang belum dibaca ───
            # { "success": true, "data": { "updated": 0 } }
        - 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,
            tapi tidak dihapus dari storage (tetap ada untuk audit). Idempotent.
          example_response: |
            { "success": true, "message": "deleted" }

            # ─── 404: id tidak ada / di luar scope ───
            # { "success": false, "message": "notification not found" }
    - id: preferences
      title: Notification Preferences API
      description: |
        Preferensi notifikasi per-user: tiap user bisa mematikan (mute) channel
        tertentu untuk kategori tertentu. Model **opt-out** — defaultnya semua ON;
        user hanya menyimpan yang ingin dimatikan. Di-scope per `(platform, user)`,
        Bearer JWT, owner-scoped (human → preferensinya sendiri; service principal
        boleh `?target-id=`).

        **Cara kerja saat kirim:** sebelum notifikasi dipersist & dikirim,
        notification-service membuang channel yang di-mute user untuk
        `category`-nya. Kalau **semua** channel termute → notifikasi disuppress
        total (tidak ada record, tidak ada kirim). Channel `inapp` yang dimute =
        record tidak muncul di list `GET /api/v1/notifications` (channel lain tetap
        terkirim bila ON).

        **Bypass:** notifikasi `mandatory` (producer set `"mandatory": true` di
        event — untuk security/akun/critical) mengabaikan preferensi sepenuhnya.
        Notifikasi ber-`target_type` ≠ `user` (mis. `org`) juga tidak difilter.
        Bila Mongo down, fail-open (kirim semua) — preferensi tidak pernah men-drop
        notifikasi karena gagal dibaca (mute tidak ditegakkan selama Mongo down).

        **Berlaku sejak diterima (frozen-at-receive):** preferensi dievaluasi saat
        notifikasi diterima. Mute yang di-set **setelah** sebuah notifikasi sudah
        diterima tidak retroaktif menghentikan retry channel yang sudah tersimpan
        untuk notifikasi itu — mute berlaku untuk notifikasi berikutnya.

        **Channel valid:** `inapp`, `email`, `fcm`, `telegram`.

        **Global mute (`"*"`):** kategori khusus `"*"` me-mute channel untuk
        **semua kategori — termasuk kategori baru di masa depan**. Contoh:
        `{"muted": {"*": ["email"]}}` = "jangan pernah email saya";
        `{"muted": {"*": ["fcm"], "marketing": ["inapp"]}}` = matikan semua push
        + sembunyikan marketing dari inbox. Mute efektif = gabungan list kategori
        + list `"*"`. Notifikasi `mandatory` tetap bypass.
      endpoints:
        - name: Get preferences
          method: GET
          path: /api/v1/preferences
          auth: Bearer JWT
          description: |
            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=`.
          example_response: |
            {
              "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)" }
        - name: Set preferences
          method: PUT
          path: /api/v1/preferences
          auth: Bearer JWT
          description: |
            **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.
          example_body: |
            {
              "muted": {
                "order_new": ["fcm"],
                "marketing": ["inapp", "fcm", "email"]
              }
            }
          example_response: |
            { "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)" }
        - 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
            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).
          example_response: |
            {
              "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": "*"
              }
            }
    - id: health
      title: Health Probes
      description: |
        Liveness (`/health`, `/live`) sengaja murah dan bebas dependency —
        orchestrator tidak boleh membunuh proses yang sekadar menunggu downstream.
        Readiness (`/ready`) menjawab "bisa kerja sekarang?" dengan cek nyata.
      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 }
        - name: Readiness
          method: GET
          path: /ready
          auth: none
          description: |
            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).
          example_response: |
            # 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
            # }
guides:
    - id: producer_quickstart
      icon: "\U0001F680"
      title: Kirim Notifikasi (untuk Service Producer)
      description: |
        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).
      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)
            rabbitmqadmin -u guest -p guest publish \
              exchange=notification.requests routing_key=notification.send payload='{
                "id": "invite:ord789:a2f32c84-639c-4778-8d30-2cc1ba3ca427",
                "platform": "wacca",
                "target_id": "a2f32c84-639c-4778-8d30-2cc1ba3ca427",
                "target_type": "user",
                "category": "member-invitation",
                "title": "Undangan member",
                "body": "Anda diundang bergabung",
                "channels": [
                  { "type": "email", "recipient": { "email": "user@example.com" },
                    "content": { "subject": "Undangan", "body": "Detail undangan…" } },
                  { "type": "fcm", "recipient": { "fcm_token": "<device token>" } }
                ],
                "source_service": "wacca-service"
              }'
        - step: 2
          title: (Fan-out) ulangi per penerima
          description: |
            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
          title: Penerima membaca inbox
          description: |
            Frontend penerima memanggil REST API dengan JWT-nya sendiri:
          curl_example: |
            curl https://notification.ikavia.com/api/v1/notifications \
              -H "Authorization: Bearer <JWT penerima>" \
              -H "X-Platform: wacca"
    - id: fcm_push
      icon: "\U0001F514"
      title: Push Notification (FCM)
      description: |
        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.
      flow:
        - step: 1
          title: App ambil device token
          description: |
            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
          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\": \"<device token dari app>\" },\n        \"content\": { \"subject\": \"Diskon 20% \U0001F389\", \"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,
            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.
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.'
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`
        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.
      protocol: amqp
      address: notification.events.queue (bound to exchange `notification.requests`, key `*.send`)
      operations:
        - type: subscribe
          summary: Notification service consumes & persists events
          description: |
            **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.
            - **`payload` di-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:
            - 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<object>
              description: '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.'
            - 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<string,string>
            - 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\": \"\U0001F4E8 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 \U0001F389\",\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 \U0001F389\", \"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
        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`/`remove` idempotent
          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_ids` per event** — event lebih besar di-DROP
          (tidak diproses). Pecah roster besar jadi beberapa event / pakai replace.
        - `account_ids` = account-UUID (`sub` JWT) 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.
theme:
    title: Notification · Ikavia
    logo_icon: "\U0001F514"
    primary_color: '#E9674D'
