feat: add scheduled bulk in-app notifications and integration guide
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1589813dae
commit
7e327440fc
10
db/migrations/000083_scheduled_in_app_notifications.down.sql
Normal file
10
db/migrations/000083_scheduled_in_app_notifications.down.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
UPDATE scheduled_notifications
|
||||
SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE channel = 'in_app' AND status IN ('pending', 'processing');
|
||||
|
||||
ALTER TABLE scheduled_notifications
|
||||
DROP CONSTRAINT IF EXISTS scheduled_notifications_channel_check;
|
||||
|
||||
ALTER TABLE scheduled_notifications
|
||||
ADD CONSTRAINT scheduled_notifications_channel_check
|
||||
CHECK (channel IN ('email', 'sms', 'push'));
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
-- Allow in_app as a scheduled notification channel.
|
||||
ALTER TABLE scheduled_notifications
|
||||
DROP CONSTRAINT IF EXISTS scheduled_notifications_channel_check;
|
||||
|
||||
ALTER TABLE scheduled_notifications
|
||||
ADD CONSTRAINT scheduled_notifications_channel_check
|
||||
CHECK (channel IN ('email', 'sms', 'push', 'in_app'));
|
||||
837
docs/bulk-scheduled-notifications-integration.md
Normal file
837
docs/bulk-scheduled-notifications-integration.md
Normal file
|
|
@ -0,0 +1,837 @@
|
|||
# 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 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 <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 |
|
||||
|
|
@ -18,6 +18,9 @@ const (
|
|||
type ScheduledNotificationTargetRaw struct {
|
||||
Phones []string `json:"phones,omitempty"`
|
||||
Emails []string `json:"emails,omitempty"`
|
||||
// In-app scheduled notifications may store optional metadata here.
|
||||
Level string `json:"level,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
type ScheduledNotification struct {
|
||||
|
|
|
|||
|
|
@ -788,6 +788,56 @@ func (s *Service) SendBulkSMS(ctx context.Context, recipients []string, message
|
|||
return sent, failed
|
||||
}
|
||||
|
||||
// SendBulkInAppNotification creates in-app notifications for each user (DB + WebSocket).
|
||||
func (s *Service) SendBulkInAppNotification(
|
||||
ctx context.Context,
|
||||
userIDs []int64,
|
||||
title, message string,
|
||||
notifType domain.NotificationType,
|
||||
level domain.NotificationLevel,
|
||||
) (sent int, failed int) {
|
||||
for _, uid := range userIDs {
|
||||
reciever := domain.NotificationRecieverSideCustomer
|
||||
receiverType := domain.ReceiverTypeUser
|
||||
if user, err := s.userSvc.GetUserByID(ctx, uid); err == nil {
|
||||
reciever = domain.ReceiverFromRole(user.Role)
|
||||
receiverType = domain.ReceiverTypeFromReciever(reciever)
|
||||
}
|
||||
|
||||
notification := &domain.Notification{
|
||||
RecipientID: uid,
|
||||
ReceiverType: receiverType,
|
||||
Type: notifType,
|
||||
Level: level,
|
||||
ErrorSeverity: domain.NotificationErrorSeverityMedium,
|
||||
Reciever: reciever,
|
||||
DeliveryChannel: domain.DeliveryChannelInApp,
|
||||
Payload: domain.NotificationPayload{
|
||||
Headline: title,
|
||||
Message: message,
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.SendNotification(ctx, notification); err != nil {
|
||||
s.mongoLogger.Error("[NotificationSvc.SendBulkInAppNotification] Failed to send",
|
||||
zap.Int64("userID", uid),
|
||||
zap.Error(err),
|
||||
)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
}
|
||||
|
||||
s.mongoLogger.Info("[NotificationSvc.SendBulkInAppNotification] Bulk in-app completed",
|
||||
zap.Int("totalRecipients", len(userIDs)),
|
||||
zap.Int("sent", sent),
|
||||
zap.Int("failed", failed),
|
||||
)
|
||||
|
||||
return sent, failed
|
||||
}
|
||||
|
||||
// SendBulkEmail sends an email to multiple recipients using the messenger service.
|
||||
// It sends sequentially and returns the count of successful and failed deliveries.
|
||||
func (s *Service) SendBulkEmail(ctx context.Context, recipients []string, subject, message, messageHTML string, attachments []*resend.Attachment) (sent int, failed int) {
|
||||
|
|
@ -1050,6 +1100,8 @@ func (s *Service) dispatchScheduledNotification(ctx context.Context, sn *domain.
|
|||
dispatchErr = s.dispatchScheduledEmail(ctx, sn)
|
||||
case domain.DeliveryChannelPush:
|
||||
dispatchErr = s.dispatchScheduledPush(ctx, sn)
|
||||
case domain.DeliveryChannelInApp:
|
||||
dispatchErr = s.dispatchScheduledInApp(ctx, sn)
|
||||
default:
|
||||
dispatchErr = fmt.Errorf("unsupported channel: %s", sn.Channel)
|
||||
}
|
||||
|
|
@ -1211,6 +1263,39 @@ func (s *Service) dispatchScheduledPush(ctx context.Context, sn *domain.Schedule
|
|||
return nil
|
||||
}
|
||||
|
||||
func scheduledInAppMeta(sn *domain.ScheduledNotification) (domain.NotificationType, domain.NotificationLevel) {
|
||||
notifType := domain.NOTIFICATION_TYPE_SYSTEM_ALERT
|
||||
level := domain.NotificationLevelInfo
|
||||
if len(sn.TargetRaw) == 0 {
|
||||
return notifType, level
|
||||
}
|
||||
var raw domain.ScheduledNotificationTargetRaw
|
||||
if err := json.Unmarshal(sn.TargetRaw, &raw); err != nil {
|
||||
return notifType, level
|
||||
}
|
||||
if t := strings.TrimSpace(raw.Type); t != "" {
|
||||
notifType = domain.NotificationType(t)
|
||||
}
|
||||
if l := strings.TrimSpace(raw.Level); l != "" {
|
||||
level = domain.NotificationLevel(l)
|
||||
}
|
||||
return notifType, level
|
||||
}
|
||||
|
||||
func (s *Service) dispatchScheduledInApp(ctx context.Context, sn *domain.ScheduledNotification) error {
|
||||
userIDs := s.resolveUserIDs(ctx, sn)
|
||||
if len(userIDs) == 0 {
|
||||
return fmt.Errorf("no in-app recipients resolved")
|
||||
}
|
||||
|
||||
notifType, level := scheduledInAppMeta(sn)
|
||||
sent, failed := s.SendBulkInAppNotification(ctx, userIDs, sn.Title, sn.Message, notifType, level)
|
||||
if sent == 0 && failed > 0 {
|
||||
return fmt.Errorf("all %d in-app deliveries failed", failed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// func (s *Service) DeleteOldNotifications(ctx context.Context) error {
|
||||
// return s.store.DeleteOldNotifications(ctx)
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ var AllPermissions = []domain.PermissionSeed{
|
|||
{Key: "notifications.bulk_sms", Name: "Bulk SMS", Description: "Send bulk SMS notifications", GroupName: "Notifications"},
|
||||
{Key: "notifications.send_email", Name: "Send Email", Description: "Send a single email", GroupName: "Notifications"},
|
||||
{Key: "notifications.bulk_email", Name: "Bulk Email", Description: "Send bulk emails", GroupName: "Notifications"},
|
||||
{Key: "notifications.bulk_in_app", Name: "Bulk In-App Notification", Description: "Send bulk in-app notifications", GroupName: "Notifications"},
|
||||
|
||||
// Scheduled Notifications
|
||||
{Key: "notifications_scheduled.list", Name: "List Scheduled Notifications", Description: "List scheduled notifications", GroupName: "Scheduled Notifications"},
|
||||
|
|
@ -462,7 +463,7 @@ var DefaultRolePermissions = map[string][]string{
|
|||
"notifications.ws_connect", "notifications.list_mine", "notifications.list_all",
|
||||
"notifications.mark_read", "notifications.mark_all_read", "notifications.mark_unread", "notifications.mark_all_unread",
|
||||
"notifications.delete_mine", "notifications.count_unread", "notifications.create",
|
||||
"notifications.test_push", "notifications.bulk_push", "notifications.bulk_sms", "notifications.send_email", "notifications.bulk_email",
|
||||
"notifications.test_push", "notifications.bulk_push", "notifications.bulk_sms", "notifications.bulk_in_app", "notifications.send_email", "notifications.bulk_email",
|
||||
"notifications_scheduled.list", "notifications_scheduled.get", "notifications_scheduled.cancel",
|
||||
|
||||
// Issues (full access including admin views)
|
||||
|
|
|
|||
|
|
@ -1217,6 +1217,146 @@ func (h *Handler) SendBulkSMS(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
// SendBulkInAppNotification godoc
|
||||
// @Summary Send bulk in-app notification
|
||||
// @Description Creates in-app notifications for specified user IDs or all users matching a role. Optionally schedule for later with scheduled_at (RFC3339).
|
||||
// @Tags notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{title=string,message=string,user_ids=[]int64,role=string,scheduled_at=string,type=string,level=string} true "Bulk in-app content"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/notifications/bulk-in-app [post]
|
||||
func (h *Handler) SendBulkInAppNotification(c *fiber.Ctx) error {
|
||||
type Request struct {
|
||||
Title string `json:"title" validate:"required"`
|
||||
Message string `json:"message" validate:"required"`
|
||||
UserIDs []int64 `json:"user_ids"`
|
||||
Role string `json:"role"`
|
||||
ScheduledAt string `json:"scheduled_at"`
|
||||
Type string `json:"type"`
|
||||
Level string `json:"level"`
|
||||
}
|
||||
|
||||
var req Request
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.Message) == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Title and message are required",
|
||||
})
|
||||
}
|
||||
|
||||
if len(req.UserIDs) == 0 && req.Role == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "No recipients specified",
|
||||
Error: "Provide user_ids or role",
|
||||
})
|
||||
}
|
||||
|
||||
notifType := domain.NOTIFICATION_TYPE_SYSTEM_ALERT
|
||||
if t := strings.TrimSpace(req.Type); t != "" {
|
||||
notifType = domain.NotificationType(t)
|
||||
}
|
||||
level := domain.NotificationLevelInfo
|
||||
if l := strings.TrimSpace(req.Level); l != "" {
|
||||
level = domain.NotificationLevel(l)
|
||||
}
|
||||
|
||||
if req.ScheduledAt != "" {
|
||||
scheduledAt, err := time.Parse(time.RFC3339, req.ScheduledAt)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid scheduled_at format, use RFC3339 (e.g. 2025-06-01T10:00:00Z)",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if scheduledAt.Before(time.Now()) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "scheduled_at must be in the future",
|
||||
})
|
||||
}
|
||||
|
||||
creatorID, _ := c.Locals("user_id").(int64)
|
||||
targetRaw, _ := json.Marshal(domain.ScheduledNotificationTargetRaw{
|
||||
Type: string(notifType),
|
||||
Level: string(level),
|
||||
})
|
||||
|
||||
sn := &domain.ScheduledNotification{
|
||||
Channel: domain.DeliveryChannelInApp,
|
||||
Title: req.Title,
|
||||
Message: req.Message,
|
||||
ScheduledAt: scheduledAt,
|
||||
TargetUserIDs: req.UserIDs,
|
||||
TargetRole: req.Role,
|
||||
TargetRaw: targetRaw,
|
||||
CreatedBy: creatorID,
|
||||
}
|
||||
|
||||
created, err := h.notificationSvc.CreateScheduledNotification(c.Context(), sn)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to schedule in-app notification",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||
Message: "In-app notification scheduled",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusCreated,
|
||||
Data: created,
|
||||
})
|
||||
}
|
||||
|
||||
userIDs := req.UserIDs
|
||||
if len(userIDs) == 0 && req.Role != "" {
|
||||
users, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: req.Role})
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to fetch users for role",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
for _, u := range users {
|
||||
userIDs = append(userIDs, u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(userIDs) == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "No target users found",
|
||||
})
|
||||
}
|
||||
|
||||
sent, failed := h.notificationSvc.SendBulkInAppNotification(c.Context(), userIDs, req.Title, req.Message, notifType, level)
|
||||
|
||||
h.mongoLoggerSvc.Info("[NotificationHandler.SendBulkInAppNotification] Bulk in-app sent",
|
||||
zap.Int("totalRecipients", len(userIDs)),
|
||||
zap.Int("sent", sent),
|
||||
zap.Int("failed", failed),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
)
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||
Message: "Bulk in-app notification sent",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
Data: map[string]interface{}{
|
||||
"total_recipients": len(userIDs),
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetScheduledNotification retrieves a single scheduled notification by ID.
|
||||
// @Summary Get scheduled notification
|
||||
// @Description Returns a single scheduled notification by its ID
|
||||
|
|
|
|||
|
|
@ -389,6 +389,7 @@ func (a *App) initAppRoutes() {
|
|||
// Bulk Notifications
|
||||
groupV1.Post("/notifications/bulk-push", a.authMiddleware, a.RequirePermission("notifications.bulk_push"), h.SendBulkPushNotification)
|
||||
groupV1.Post("/notifications/bulk-sms", a.authMiddleware, a.RequirePermission("notifications.bulk_sms"), h.SendBulkSMS)
|
||||
groupV1.Post("/notifications/bulk-in-app", a.authMiddleware, a.RequirePermission("notifications.bulk_in_app"), h.SendBulkInAppNotification)
|
||||
groupV1.Post("/notifications/send-email", a.authMiddleware, a.RequirePermission("notifications.send_email"), h.SendSingleEmail)
|
||||
groupV1.Post("/notifications/bulk-email", a.authMiddleware, a.RequirePermission("notifications.bulk_email"), h.SendBulkEmail)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user