Yimaru-BackEnd/docs/bulk-scheduled-notifications-integration.md
2026-06-12 04:57:03 -07:00

838 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <access_token>
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 <token>
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 <token>" \
-F "subject=New course available" \
-F "html=<p>Check out our new module!</p>" \
-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 <token>" \
-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 <token>" \
-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 <token>" \
-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 PM1 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 <token>
```
#### 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=<access_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 |