diff --git a/db/migrations/000083_scheduled_in_app_notifications.down.sql b/db/migrations/000083_scheduled_in_app_notifications.down.sql new file mode 100644 index 0000000..547faab --- /dev/null +++ b/db/migrations/000083_scheduled_in_app_notifications.down.sql @@ -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')); diff --git a/db/migrations/000083_scheduled_in_app_notifications.up.sql b/db/migrations/000083_scheduled_in_app_notifications.up.sql new file mode 100644 index 0000000..727c09a --- /dev/null +++ b/db/migrations/000083_scheduled_in_app_notifications.up.sql @@ -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')); diff --git a/docs/bulk-scheduled-notifications-integration.md b/docs/bulk-scheduled-notifications-integration.md new file mode 100644 index 0000000..1628d66 --- /dev/null +++ b/docs/bulk-scheduled-notifications-integration.md @@ -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 +Content-Type: application/json +``` + +(Push and email use `multipart/form-data`; see below.) + +### Required RBAC permissions + +| Action | Permission key | +|--------|----------------| +| Bulk SMS | `notifications.bulk_sms` | +| Bulk email | `notifications.bulk_email` | +| Bulk push | `notifications.bulk_push` | +| Bulk in-app | `notifications.bulk_in_app` | +| List scheduled jobs | `notifications_scheduled.list` | +| Get scheduled job | `notifications_scheduled.get` | +| Cancel scheduled job | `notifications_scheduled.cancel` | + +Team members with the **ADMIN** role bundle receive these permissions after RBAC seeding. Ensure migration `000083` has been applied for `in_app` scheduled channel support. + +--- + +## Immediate vs scheduled + +| `scheduled_at` | Behavior | HTTP status | +|----------------|----------|-------------| +| **Omitted or empty** | Send immediately | `200 OK` | +| **Present, RFC3339, future** | Create scheduled job | `201 Created` | +| **Present, past** | Rejected | `400 Bad Request` | +| **Invalid format** | Rejected | `400 Bad Request` | + +**Format:** RFC3339 UTC recommended, e.g. `2026-06-15T09:00:00Z` + +--- + +## Shared response envelopes + +### Success (`domain.Response`) + +```json +{ + "message": "Human-readable summary", + "data": { }, + "success": true, + "status_code": 200, + "metadata": null +} +``` + +### Error (`domain.ErrorResponse`) + +```json +{ + "message": "Short error title", + "error": "Optional detail string" +} +``` + +### Immediate send `data` shape (SMS, email, in-app) + +```json +{ + "total_recipients": 150, + "sent": 148, + "failed": 2 +} +``` + +### Immediate push `data` shape + +```json +{ + "target_users": 150, + "sent": 200, + "failed": 5, + "image": "https://api.example.com/files/notification_images/abc.jpg" +} +``` + +> Push `sent`/`failed` count **device tokens**, not users. One user may have multiple devices. + +--- + +## Scheduled notification object + +Returned when scheduling (`201`) and from management GET endpoints. + +```json +{ + "id": 42, + "channel": "sms", + "title": "Optional subject / headline", + "message": "Body text", + "html": "Optional HTML (email only)", + "scheduled_at": "2026-06-15T09:00:00.000Z", + "status": "pending", + "target_user_ids": [1, 2, 3], + "target_role": "STUDENT", + "target_raw": { "phones": ["+251911000000"] }, + "attempt_count": 0, + "last_error": "", + "processing_started_at": null, + "sent_at": null, + "cancelled_at": null, + "created_by": 7, + "created_at": "2026-06-10T12:00:00.000Z", + "updated_at": "2026-06-10T12:00:00.000Z" +} +``` + +### `channel` values + +| Value | Description | +|-------|-------------| +| `sms` | AfroMessage SMS | +| `email` | Resend / messenger email | +| `push` | Firebase Cloud Messaging | +| `in_app` | DB notification + WebSocket broadcast | + +### `status` lifecycle + +| Status | Meaning | +|--------|---------| +| `pending` | Waiting for `scheduled_at` | +| `processing` | Claimed by worker, dispatch in progress | +| `sent` | Dispatch completed (at least one recipient succeeded) | +| `failed` | All deliveries failed or unsupported channel | +| `cancelled` | Cancelled via API before send | + +### `target_raw` structure + +Used when recipients cannot be expressed only via `target_user_ids` / `target_role`: + +```json +{ + "phones": ["+251911000000", "+251922000000"], + "emails": ["user@example.com"], + "type": "system_alert", + "level": "info" +} +``` + +| Field | Used by | +|-------|---------| +| `phones` | Scheduled SMS with direct phone numbers | +| `emails` | Scheduled email with direct addresses | +| `type`, `level` | Scheduled in-app metadata (defaults: `system_alert`, `info`) | + +--- + +## Bulk SMS + +### Endpoint + +``` +POST /api/v1/notifications/bulk-sms +Content-Type: application/json +Permission: notifications.bulk_sms +``` + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `message` | string | **Yes** | SMS body | +| `user_ids` | int64[] | No* | Platform user IDs (resolved to `phone_number`) | +| `role` | string | No* | All users with this platform role | +| `phone_numbers` | string[] | No* | Direct E.164 or local numbers | +| `scheduled_at` | string | No | RFC3339; omit for immediate send | + +\* At least one of `user_ids`, `role`, or `phone_numbers` is required. + +### Example — immediate + +```http +POST /api/v1/notifications/bulk-sms +Authorization: Bearer +Content-Type: application/json + +{ + "message": "Your subscription expires tomorrow.", + "role": "STUDENT" +} +``` + +### Example — scheduled + +```json +{ + "message": "Reminder: class starts at 9 AM.", + "user_ids": [10, 11, 12], + "scheduled_at": "2026-06-15T08:00:00Z" +} +``` + +### Example — scheduled with direct phones + +```json +{ + "message": "Welcome to Yimaru!", + "phone_numbers": ["+251911000000"], + "scheduled_at": "2026-06-15T09:00:00Z" +} +``` + +### Success responses + +**Immediate (`200`):** + +```json +{ + "message": "Bulk SMS sent", + "success": true, + "status_code": 200, + "data": { + "total_recipients": 3, + "sent": 3, + "failed": 0 + } +} +``` + +**Scheduled (`201`):** + +```json +{ + "message": "SMS scheduled", + "success": true, + "status_code": 201, + "data": { /* ScheduledNotification */ } +} +``` + +--- + +## Bulk email + +### Endpoint + +``` +POST /api/v1/notifications/bulk-email +Content-Type: multipart/form-data +Permission: notifications.bulk_email +``` + +### Form fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `subject` | string | **Yes** | Email subject (stored as `title` when scheduled) | +| `message` | string | No** | Plain-text body | +| `html` | string | No** | HTML body | +| `user_ids` | string | No* | JSON array string, e.g. `[1,2,3]` | +| `role` | string | No* | Platform role filter | +| `emails` | string | No* | JSON array string, e.g. `["a@b.com"]` | +| `scheduled_at` | string | No | RFC3339 | +| `file` | file | No | Attachment (immediate send only) | + +\* At least one of `user_ids`, `role`, or `emails` is required. + +\** At least one of `message` or `html` is required. + +### Example — immediate (cURL) + +```bash +curl -X POST "https://api.example.com/api/v1/notifications/bulk-email" \ + -H "Authorization: Bearer " \ + -F "subject=New course available" \ + -F "html=

Check out our new module!

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