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 {
|
type ScheduledNotificationTargetRaw struct {
|
||||||
Phones []string `json:"phones,omitempty"`
|
Phones []string `json:"phones,omitempty"`
|
||||||
Emails []string `json:"emails,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 {
|
type ScheduledNotification struct {
|
||||||
|
|
|
||||||
|
|
@ -788,6 +788,56 @@ func (s *Service) SendBulkSMS(ctx context.Context, recipients []string, message
|
||||||
return sent, failed
|
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.
|
// SendBulkEmail sends an email to multiple recipients using the messenger service.
|
||||||
// It sends sequentially and returns the count of successful and failed deliveries.
|
// 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) {
|
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)
|
dispatchErr = s.dispatchScheduledEmail(ctx, sn)
|
||||||
case domain.DeliveryChannelPush:
|
case domain.DeliveryChannelPush:
|
||||||
dispatchErr = s.dispatchScheduledPush(ctx, sn)
|
dispatchErr = s.dispatchScheduledPush(ctx, sn)
|
||||||
|
case domain.DeliveryChannelInApp:
|
||||||
|
dispatchErr = s.dispatchScheduledInApp(ctx, sn)
|
||||||
default:
|
default:
|
||||||
dispatchErr = fmt.Errorf("unsupported channel: %s", sn.Channel)
|
dispatchErr = fmt.Errorf("unsupported channel: %s", sn.Channel)
|
||||||
}
|
}
|
||||||
|
|
@ -1211,6 +1263,39 @@ func (s *Service) dispatchScheduledPush(ctx context.Context, sn *domain.Schedule
|
||||||
return nil
|
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 {
|
// func (s *Service) DeleteOldNotifications(ctx context.Context) error {
|
||||||
// return s.store.DeleteOldNotifications(ctx)
|
// 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.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.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_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
|
// Scheduled Notifications
|
||||||
{Key: "notifications_scheduled.list", Name: "List Scheduled Notifications", Description: "List scheduled notifications", GroupName: "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.ws_connect", "notifications.list_mine", "notifications.list_all",
|
||||||
"notifications.mark_read", "notifications.mark_all_read", "notifications.mark_unread", "notifications.mark_all_unread",
|
"notifications.mark_read", "notifications.mark_all_read", "notifications.mark_unread", "notifications.mark_all_unread",
|
||||||
"notifications.delete_mine", "notifications.count_unread", "notifications.create",
|
"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",
|
"notifications_scheduled.list", "notifications_scheduled.get", "notifications_scheduled.cancel",
|
||||||
|
|
||||||
// Issues (full access including admin views)
|
// 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.
|
// GetScheduledNotification retrieves a single scheduled notification by ID.
|
||||||
// @Summary Get scheduled notification
|
// @Summary Get scheduled notification
|
||||||
// @Description Returns a single scheduled notification by its ID
|
// @Description Returns a single scheduled notification by its ID
|
||||||
|
|
|
||||||
|
|
@ -389,6 +389,7 @@ func (a *App) initAppRoutes() {
|
||||||
// Bulk Notifications
|
// Bulk Notifications
|
||||||
groupV1.Post("/notifications/bulk-push", a.authMiddleware, a.RequirePermission("notifications.bulk_push"), h.SendBulkPushNotification)
|
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-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/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)
|
groupV1.Post("/notifications/bulk-email", a.authMiddleware, a.RequirePermission("notifications.bulk_email"), h.SendBulkEmail)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user