# Bulk & Scheduled Notifications — Integration Guide This guide documents how admin clients integrate with the Yimaru backend to send **immediate** or **scheduled** bulk notifications across four channels: | Channel | Value | Endpoint | |---------|-------|----------| | SMS | `sms` | `POST /api/v1/notifications/bulk-sms` | | Email | `email` | `POST /api/v1/notifications/bulk-email` | | Push (FCM) | `push` | `POST /api/v1/notifications/bulk-push` | | In-app | `in_app` | `POST /api/v1/notifications/bulk-in-app` | Scheduled jobs are created by the same bulk endpoints when `scheduled_at` is provided. Management APIs list, inspect, and cancel pending jobs. --- ## Table of contents 1. [Architecture overview](#architecture-overview) 2. [Authentication & permissions](#authentication--permissions) 3. [Immediate vs scheduled](#immediate-vs-scheduled) 4. [Shared response envelopes](#shared-response-envelopes) 5. [Scheduled notification object](#scheduled-notification-object) 6. [Bulk SMS](#bulk-sms) 7. [Bulk email](#bulk-email) 8. [Bulk push](#bulk-push) 9. [Bulk in-app](#bulk-in-app) 10. [Scheduled job management](#scheduled-job-management) 11. [In-app real-time delivery (WebSocket)](#in-app-real-time-delivery-websocket) 12. [Recipient targeting rules](#recipient-targeting-rules) 13. [Error handling](#error-handling) 14. [Operational notes & limitations](#operational-notes--limitations) 15. [Admin UI integration checklist](#admin-ui-integration-checklist) --- ## Architecture overview ```mermaid sequenceDiagram participant Admin as Admin client participant API as Yimaru API participant DB as PostgreSQL participant Worker as Scheduler worker participant Channel as SMS / Email / FCM / WS alt Immediate send (no scheduled_at) Admin->>API: POST /notifications/bulk-* API->>Channel: Deliver now API-->>Admin: 200 + sent/failed counts else Scheduled send (scheduled_at set) Admin->>API: POST /notifications/bulk-* + scheduled_at API->>DB: INSERT scheduled_notifications (pending) API-->>Admin: 201 + ScheduledNotification loop Every ~30s Worker->>DB: Claim due pending rows Worker->>Channel: Dispatch bulk delivery Worker->>DB: Mark sent or failed end end ``` - The scheduler runs **in-process** on each API instance (30-second poll). - Row claiming uses `FOR UPDATE SKIP LOCKED` so multiple API replicas can run safely. - There is **no separate “create scheduled job” endpoint** — scheduling is done via `scheduled_at` on the bulk send endpoints. --- ## Authentication & permissions All bulk and scheduled-management endpoints require: ```http Authorization: Bearer Content-Type: application/json ``` (Push and email use `multipart/form-data`; see below.) ### Required RBAC permissions | Action | Permission key | |--------|----------------| | Bulk SMS | `notifications.bulk_sms` | | Bulk email | `notifications.bulk_email` | | Bulk push | `notifications.bulk_push` | | Bulk in-app | `notifications.bulk_in_app` | | List scheduled jobs | `notifications_scheduled.list` | | Get scheduled job | `notifications_scheduled.get` | | Cancel scheduled job | `notifications_scheduled.cancel` | Team members with the **ADMIN** role bundle receive these permissions after RBAC seeding. Ensure migration `000083` has been applied for `in_app` scheduled channel support. --- ## Immediate vs scheduled | `scheduled_at` | Behavior | HTTP status | |----------------|----------|-------------| | **Omitted or empty** | Send immediately | `200 OK` | | **Present, RFC3339, future** | Create scheduled job | `201 Created` | | **Present, past** | Rejected | `400 Bad Request` | | **Invalid format** | Rejected | `400 Bad Request` | **Format:** RFC3339 UTC recommended, e.g. `2026-06-15T09:00:00Z` --- ## Shared response envelopes ### Success (`domain.Response`) ```json { "message": "Human-readable summary", "data": { }, "success": true, "status_code": 200, "metadata": null } ``` ### Error (`domain.ErrorResponse`) ```json { "message": "Short error title", "error": "Optional detail string" } ``` ### Immediate send `data` shape (SMS, email, in-app) ```json { "total_recipients": 150, "sent": 148, "failed": 2 } ``` ### Immediate push `data` shape ```json { "target_users": 150, "sent": 200, "failed": 5, "image": "https://api.example.com/files/notification_images/abc.jpg" } ``` > Push `sent`/`failed` count **device tokens**, not users. One user may have multiple devices. --- ## Scheduled notification object Returned when scheduling (`201`) and from management GET endpoints. ```json { "id": 42, "channel": "sms", "title": "Optional subject / headline", "message": "Body text", "html": "Optional HTML (email only)", "scheduled_at": "2026-06-15T09:00:00.000Z", "status": "pending", "target_user_ids": [1, 2, 3], "target_role": "STUDENT", "target_raw": { "phones": ["+251911000000"] }, "attempt_count": 0, "last_error": "", "processing_started_at": null, "sent_at": null, "cancelled_at": null, "created_by": 7, "created_at": "2026-06-10T12:00:00.000Z", "updated_at": "2026-06-10T12:00:00.000Z" } ``` ### `channel` values | Value | Description | |-------|-------------| | `sms` | AfroMessage SMS | | `email` | Resend / messenger email | | `push` | Firebase Cloud Messaging | | `in_app` | DB notification + WebSocket broadcast | ### `status` lifecycle | Status | Meaning | |--------|---------| | `pending` | Waiting for `scheduled_at` | | `processing` | Claimed by worker, dispatch in progress | | `sent` | Dispatch completed (at least one recipient succeeded) | | `failed` | All deliveries failed or unsupported channel | | `cancelled` | Cancelled via API before send | ### `target_raw` structure Used when recipients cannot be expressed only via `target_user_ids` / `target_role`: ```json { "phones": ["+251911000000", "+251922000000"], "emails": ["user@example.com"], "type": "system_alert", "level": "info" } ``` | Field | Used by | |-------|---------| | `phones` | Scheduled SMS with direct phone numbers | | `emails` | Scheduled email with direct addresses | | `type`, `level` | Scheduled in-app metadata (defaults: `system_alert`, `info`) | --- ## Bulk SMS ### Endpoint ``` POST /api/v1/notifications/bulk-sms Content-Type: application/json Permission: notifications.bulk_sms ``` ### Request body | Field | Type | Required | Description | |-------|------|----------|-------------| | `message` | string | **Yes** | SMS body | | `user_ids` | int64[] | No* | Platform user IDs (resolved to `phone_number`) | | `role` | string | No* | All users with this platform role | | `phone_numbers` | string[] | No* | Direct E.164 or local numbers | | `scheduled_at` | string | No | RFC3339; omit for immediate send | \* At least one of `user_ids`, `role`, or `phone_numbers` is required. ### Example — immediate ```http POST /api/v1/notifications/bulk-sms Authorization: Bearer Content-Type: application/json { "message": "Your subscription expires tomorrow.", "role": "STUDENT" } ``` ### Example — scheduled ```json { "message": "Reminder: class starts at 9 AM.", "user_ids": [10, 11, 12], "scheduled_at": "2026-06-15T08:00:00Z" } ``` ### Example — scheduled with direct phones ```json { "message": "Welcome to Yimaru!", "phone_numbers": ["+251911000000"], "scheduled_at": "2026-06-15T09:00:00Z" } ``` ### Success responses **Immediate (`200`):** ```json { "message": "Bulk SMS sent", "success": true, "status_code": 200, "data": { "total_recipients": 3, "sent": 3, "failed": 0 } } ``` **Scheduled (`201`):** ```json { "message": "SMS scheduled", "success": true, "status_code": 201, "data": { /* ScheduledNotification */ } } ``` --- ## Bulk email ### Endpoint ``` POST /api/v1/notifications/bulk-email Content-Type: multipart/form-data Permission: notifications.bulk_email ``` ### Form fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `subject` | string | **Yes** | Email subject (stored as `title` when scheduled) | | `message` | string | No** | Plain-text body | | `html` | string | No** | HTML body | | `user_ids` | string | No* | JSON array string, e.g. `[1,2,3]` | | `role` | string | No* | Platform role filter | | `emails` | string | No* | JSON array string, e.g. `["a@b.com"]` | | `scheduled_at` | string | No | RFC3339 | | `file` | file | No | Attachment (immediate send only) | \* At least one of `user_ids`, `role`, or `emails` is required. \** At least one of `message` or `html` is required. ### Example — immediate (cURL) ```bash curl -X POST "https://api.example.com/api/v1/notifications/bulk-email" \ -H "Authorization: Bearer " \ -F "subject=New course available" \ -F "html=

Check out our new module!

" \ -F "role=STUDENT" \ -F "file=@banner.png" ``` ### Example — scheduled (cURL) ```bash curl -X POST "https://api.example.com/api/v1/notifications/bulk-email" \ -H "Authorization: Bearer " \ -F "subject=Weekly digest" \ -F "message=Here is your weekly summary." \ -F "emails=[\"admin@example.com\",\"user@example.com\"]" \ -F "scheduled_at=2026-06-15T09:00:00Z" ``` > **Note:** Attachments are **not** stored for scheduled emails. Upload and send immediately if an attachment is required. ### Success responses **Immediate (`200`):** ```json { "message": "Bulk email sent", "success": true, "status_code": 200, "data": { "total_recipients": 50, "sent": 49, "failed": 1 } } ``` **Scheduled (`201`):** ```json { "message": "Email scheduled", "success": true, "status_code": 201, "data": { "id": 43, "channel": "email", "title": "Weekly digest", "message": "Here is your weekly summary.", "html": "", "scheduled_at": "2026-06-15T09:00:00.000Z", "status": "pending", "target_raw": { "emails": ["admin@example.com", "user@example.com"] }, "created_by": 7 } } ``` --- ## Bulk push ### Endpoint ``` POST /api/v1/notifications/bulk-push Content-Type: multipart/form-data Permission: notifications.bulk_push ``` Requires **FCM** credentials (`FCM_SERVICE_ACCOUNT_KEY`) on the server. ### Form fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `title` | string | **Yes** | Notification title | | `message` | string | **Yes** | Notification body | | `user_ids` | string | No* | JSON array string, e.g. `[1,2,3]` | | `role` | string | No* | Platform role filter | | `scheduled_at` | string | No | RFC3339 | | `file` | file | No | Image (immediate send only) | \* At least one of `user_ids` or `role` is required. ### Example — immediate ```bash curl -X POST "https://api.example.com/api/v1/notifications/bulk-push" \ -H "Authorization: Bearer " \ -F "title=Practice reminder" \ -F "message=Complete today's practice." \ -F "user_ids=[1,2,3]" \ -F "file=@thumb.jpg" ``` ### Example — scheduled ```bash curl -X POST "https://api.example.com/api/v1/notifications/bulk-push" \ -H "Authorization: Bearer " \ -F "title=Exam tomorrow" \ -F "message=Don't forget to review Unit 3." \ -F "role=STUDENT" \ -F "scheduled_at=2026-06-14T18:00:00Z" ``` > **Note:** Push images are **not** preserved when scheduling. Use immediate send for image pushes. ### Success responses **Immediate (`200`):** ```json { "message": "Bulk push notification sent", "success": true, "status_code": 200, "data": { "target_users": 3, "sent": 4, "failed": 0, "image": "https://api.example.com/files/notification_images/abc.jpg" } } ``` **Scheduled (`201`):** ```json { "message": "Push notification scheduled", "success": true, "status_code": 201, "data": { /* ScheduledNotification, channel: "push" */ } } ``` --- ## Bulk in-app In-app notifications are persisted in the `notifications` table and pushed to connected clients via WebSocket (`CREATED_NOTIFICATION` event). Users without an active socket still see them on next `GET /api/v1/notifications`. ### Endpoint ``` POST /api/v1/notifications/bulk-in-app Content-Type: application/json Permission: notifications.bulk_in_app ``` ### Request body | Field | Type | Required | Description | |-------|------|----------|-------------| | `title` | string | **Yes** | Headline (`payload.headline`) | | `message` | string | **Yes** | Body (`payload.message`) | | `user_ids` | int64[] | No* | Target user IDs | | `role` | string | No* | All users with platform role | | `scheduled_at` | string | No | RFC3339 | | `type` | string | No | Notification type (default `system_alert`) | | `level` | string | No | `info`, `warning`, `error`, `success` (default `info`) | \* At least one of `user_ids` or `role` is required. ### Notification `type` values (common) | Value | Use case | |-------|----------| | `system_alert` | Admin broadcasts (default) | | `subscription_expiring` | Billing reminders | | `course_completed` | Learning milestones | | `payment_verified` | Payment events | ### Example — immediate ```json { "title": "Maintenance tonight", "message": "The app will be unavailable 11 PM–1 AM UTC.", "role": "STUDENT" } ``` ### Example — scheduled ```json { "title": "New practice unlocked", "message": "Unit 4 practice is now available.", "user_ids": [100, 101, 102], "type": "system_alert", "level": "info", "scheduled_at": "2026-06-16T07:00:00Z" } ``` ### Success responses **Immediate (`200`):** ```json { "message": "Bulk in-app notification sent", "success": true, "status_code": 200, "data": { "total_recipients": 3, "sent": 3, "failed": 0 } } ``` **Scheduled (`201`):** ```json { "message": "In-app notification scheduled", "success": true, "status_code": 201, "data": { "id": 44, "channel": "in_app", "title": "New practice unlocked", "message": "Unit 4 practice is now available.", "scheduled_at": "2026-06-16T07:00:00.000Z", "status": "pending", "target_user_ids": [100, 101, 102], "target_raw": { "type": "system_alert", "level": "info" } } } ``` --- ## Scheduled job management ### List scheduled notifications ``` GET /api/v1/notifications/scheduled Permission: notifications_scheduled.list ``` #### Query parameters | Param | Type | Default | Description | |-------|------|---------|-------------| | `status` | string | — | `pending`, `processing`, `sent`, `failed`, `cancelled` | | `channel` | string | — | `sms`, `email`, `push`, `in_app` | | `after` | RFC3339 | — | `scheduled_at >= after` | | `before` | RFC3339 | — | `scheduled_at <= before` | | `limit` | int | 20 | Page size | | `page` | int | 1 | Page number (1-based) | #### Example ```http GET /api/v1/notifications/scheduled?channel=in_app&status=pending&page=1&limit=20 Authorization: Bearer ``` #### Response ```json { "scheduled_notifications": [ /* ScheduledNotification[] */ ], "total_count": 42, "limit": 20, "page": 1 } ``` ### Get one scheduled notification ``` GET /api/v1/notifications/scheduled/:id Permission: notifications_scheduled.get ``` #### Response (`200`) ```json { "message": "Scheduled notification retrieved", "success": true, "status_code": 200, "data": { /* ScheduledNotification */ } } ``` ### Cancel scheduled notification ``` POST /api/v1/notifications/scheduled/:id/cancel Permission: notifications_scheduled.cancel ``` Only jobs with status `pending` or `processing` can be cancelled. #### Response (`200`) ```json { "message": "Scheduled notification cancelled", "success": true, "status_code": 200, "data": { "id": 42, "status": "cancelled", "cancelled_at": "2026-06-10T14:30:00.000Z" } } ``` --- ## In-app real-time delivery (WebSocket) Mobile/web clients should connect to receive in-app notifications instantly. ### Connect ``` GET /api/v1/ws/connect?token= Permission: notifications.ws_connect (validated via query token) ``` Auth uses the **JWT access token in query string** (not `Authorization` header). ### Broadcast payload When an in-app notification is created, connected clients receive: ```json { "type": "CREATED_NOTIFICATION", "recipient_id": 123, "payload": { "id": "notif-uuid", "recipient_id": 123, "type": "system_alert", "level": "info", "delivery_channel": "in_app", "payload": { "headline": "Title", "message": "Body" }, "is_read": false, "timestamp": "2026-06-10T12:00:00.000Z" } } ``` ### Polling fallback ``` GET /api/v1/notifications?limit=20&offset=0 Permission: notifications.list_mine ``` ``` GET /api/v1/notifications/unread Permission: notifications.count_unread ``` --- ## Recipient targeting rules ### Platform roles (`role` field) Valid platform `users.role` values: | Role | Value | |------|-------| | Student | `STUDENT` | | Open learner | `OPEN_LEARNER` | | Instructor | `INSTRUCTOR` | | Admin | `ADMIN` | | Super admin | `SUPER_ADMIN` | | Support | `SUPPORT` | When `role` is set without `user_ids`, the API loads **all users** matching that role. ### Combining targets | Channel | `user_ids` | `role` | Direct (`phones` / `emails`) | |---------|------------|--------|------------------------------| | SMS | ✓ | ✓ | `phone_numbers` | | Email | ✓ | ✓ | `emails` (form field) | | Push | ✓ | ✓ | — | | In-app | ✓ | ✓ | — | For immediate SMS/email, all sources are **unioned** (deduplicated). Scheduled jobs store the targeting fields as-is and resolve at dispatch time. --- ## Error handling ### Common `400` errors | Message | Cause | |---------|-------| | `Message is required` | Empty SMS/in-app body | | `Title is required` | Empty push/in-app title | | `No recipients specified` | Missing all targeting fields | | `No target users found` | Role/user_ids resolved to zero users | | `Invalid scheduled_at format` | Not RFC3339 | | `scheduled_at must be in the future` | Past timestamp | | `Invalid user_ids format` | Push/email: not a JSON array string | ### Common `401` / `403` | Situation | Result | |-----------|--------| | Missing/invalid Bearer token | `401` | | Missing RBAC permission | `403` | ### Common `500` | Situation | Result | |-----------|--------| | FCM not configured (push) | Push send fails | | AfroMessage / Resend misconfigured | SMS/email failures | | DB error creating scheduled row | Schedule request fails | ### Partial delivery For immediate sends, the API returns `sent` and `failed` counts. The HTTP status is still `200` if the request was processed. For scheduled jobs, if **at least one** recipient succeeds, status becomes `sent`. If **all** fail, status becomes `failed` and `last_error` is set. --- ## Operational notes & limitations | Topic | Detail | |-------|--------| | Scheduler interval | ~30 seconds (not exact-to-the-second) | | Scheduled push images | Not supported — image upload only on immediate push | | Scheduled email attachments | Not supported — attachments only on immediate send | | Scheduled job retry | No automatic retry; failed jobs stay `failed` | | Edit / reschedule | Not supported — cancel and create a new job | | Multi-instance | Safe via DB row locking | | SMS provider | AfroMessage | | Email provider | Resend (messenger service) | | Push provider | Firebase Cloud Messaging | | History records | Immediate SMS/email/push call `RecordNotification` for known `user_ids`; scheduled SMS/email/push do **not** write per-user history rows. In-app always creates notification rows (immediate and scheduled). | | Migration | `000083` required for `in_app` scheduled channel | --- ## Admin UI integration checklist ### Compose screen (per channel) - [ ] Title/subject + message fields (channel-specific) - [ ] Recipient picker: user multi-select, role dropdown, and/or direct phones/emails - [ ] Toggle: **Send now** vs **Schedule** - [ ] Date/time picker → serialize as RFC3339 UTC (`scheduled_at`) - [ ] Optional: image upload (push immediate only), email attachment (immediate only) - [ ] Optional: in-app `type` and `level` selectors ### After submit - [ ] Immediate: show `sent` / `failed` / `total_recipients` from `data` - [ ] Scheduled: show returned `id`, `scheduled_at`, `status` — link to job detail view ### Scheduled jobs list - [ ] Call `GET /notifications/scheduled` with filters (`status`, `channel`, date range) - [ ] Paginate with `page` + `limit` - [ ] Show status badges: pending → processing → sent / failed / cancelled - [ ] Cancel button → `POST /notifications/scheduled/:id/cancel` for `pending`/`processing` ### Learner app (in-app only) - [ ] WebSocket connect on login with `?token=` - [ ] Handle `CREATED_NOTIFICATION` events - [ ] Fallback poll `GET /notifications` and `GET /notifications/unread` --- ## Quick reference — all endpoints | Method | Path | Purpose | |--------|------|---------| | `POST` | `/api/v1/notifications/bulk-sms` | Bulk / schedule SMS | | `POST` | `/api/v1/notifications/bulk-email` | Bulk / schedule email | | `POST` | `/api/v1/notifications/bulk-push` | Bulk / schedule push | | `POST` | `/api/v1/notifications/bulk-in-app` | Bulk / schedule in-app | | `GET` | `/api/v1/notifications/scheduled` | List scheduled jobs | | `GET` | `/api/v1/notifications/scheduled/:id` | Get scheduled job | | `POST` | `/api/v1/notifications/scheduled/:id/cancel` | Cancel scheduled job | | `GET` | `/api/v1/ws/connect?token=` | In-app WebSocket | | `GET` | `/api/v1/notifications` | User notification inbox |