feat: add scheduled bulk in-app notifications and integration guide

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-12 04:57:03 -07:00
parent 1589813dae
commit 7e327440fc
8 changed files with 1085 additions and 1 deletions

View 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'));

View File

@ -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'));

View 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 PM1 AM UTC.",
"role": "STUDENT"
}
```
### Example — scheduled
```json
{
"title": "New practice unlocked",
"message": "Unit 4 practice is now available.",
"user_ids": [100, 101, 102],
"type": "system_alert",
"level": "info",
"scheduled_at": "2026-06-16T07:00:00Z"
}
```
### Success responses
**Immediate (`200`):**
```json
{
"message": "Bulk in-app notification sent",
"success": true,
"status_code": 200,
"data": {
"total_recipients": 3,
"sent": 3,
"failed": 0
}
}
```
**Scheduled (`201`):**
```json
{
"message": "In-app notification scheduled",
"success": true,
"status_code": 201,
"data": {
"id": 44,
"channel": "in_app",
"title": "New practice unlocked",
"message": "Unit 4 practice is now available.",
"scheduled_at": "2026-06-16T07:00:00.000Z",
"status": "pending",
"target_user_ids": [100, 101, 102],
"target_raw": { "type": "system_alert", "level": "info" }
}
}
```
---
## Scheduled job management
### List scheduled notifications
```
GET /api/v1/notifications/scheduled
Permission: notifications_scheduled.list
```
#### Query parameters
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `status` | string | — | `pending`, `processing`, `sent`, `failed`, `cancelled` |
| `channel` | string | — | `sms`, `email`, `push`, `in_app` |
| `after` | RFC3339 | — | `scheduled_at >= after` |
| `before` | RFC3339 | — | `scheduled_at <= before` |
| `limit` | int | 20 | Page size |
| `page` | int | 1 | Page number (1-based) |
#### Example
```http
GET /api/v1/notifications/scheduled?channel=in_app&status=pending&page=1&limit=20
Authorization: Bearer <token>
```
#### Response
```json
{
"scheduled_notifications": [ /* ScheduledNotification[] */ ],
"total_count": 42,
"limit": 20,
"page": 1
}
```
### Get one scheduled notification
```
GET /api/v1/notifications/scheduled/:id
Permission: notifications_scheduled.get
```
#### Response (`200`)
```json
{
"message": "Scheduled notification retrieved",
"success": true,
"status_code": 200,
"data": { /* ScheduledNotification */ }
}
```
### Cancel scheduled notification
```
POST /api/v1/notifications/scheduled/:id/cancel
Permission: notifications_scheduled.cancel
```
Only jobs with status `pending` or `processing` can be cancelled.
#### Response (`200`)
```json
{
"message": "Scheduled notification cancelled",
"success": true,
"status_code": 200,
"data": {
"id": 42,
"status": "cancelled",
"cancelled_at": "2026-06-10T14:30:00.000Z"
}
}
```
---
## In-app real-time delivery (WebSocket)
Mobile/web clients should connect to receive in-app notifications instantly.
### Connect
```
GET /api/v1/ws/connect?token=<access_token>
Permission: notifications.ws_connect (validated via query token)
```
Auth uses the **JWT access token in query string** (not `Authorization` header).
### Broadcast payload
When an in-app notification is created, connected clients receive:
```json
{
"type": "CREATED_NOTIFICATION",
"recipient_id": 123,
"payload": {
"id": "notif-uuid",
"recipient_id": 123,
"type": "system_alert",
"level": "info",
"delivery_channel": "in_app",
"payload": {
"headline": "Title",
"message": "Body"
},
"is_read": false,
"timestamp": "2026-06-10T12:00:00.000Z"
}
}
```
### Polling fallback
```
GET /api/v1/notifications?limit=20&offset=0
Permission: notifications.list_mine
```
```
GET /api/v1/notifications/unread
Permission: notifications.count_unread
```
---
## Recipient targeting rules
### Platform roles (`role` field)
Valid platform `users.role` values:
| Role | Value |
|------|-------|
| Student | `STUDENT` |
| Open learner | `OPEN_LEARNER` |
| Instructor | `INSTRUCTOR` |
| Admin | `ADMIN` |
| Super admin | `SUPER_ADMIN` |
| Support | `SUPPORT` |
When `role` is set without `user_ids`, the API loads **all users** matching that role.
### Combining targets
| Channel | `user_ids` | `role` | Direct (`phones` / `emails`) |
|---------|------------|--------|------------------------------|
| SMS | ✓ | ✓ | `phone_numbers` |
| Email | ✓ | ✓ | `emails` (form field) |
| Push | ✓ | ✓ | — |
| In-app | ✓ | ✓ | — |
For immediate SMS/email, all sources are **unioned** (deduplicated). Scheduled jobs store the targeting fields as-is and resolve at dispatch time.
---
## Error handling
### Common `400` errors
| Message | Cause |
|---------|-------|
| `Message is required` | Empty SMS/in-app body |
| `Title is required` | Empty push/in-app title |
| `No recipients specified` | Missing all targeting fields |
| `No target users found` | Role/user_ids resolved to zero users |
| `Invalid scheduled_at format` | Not RFC3339 |
| `scheduled_at must be in the future` | Past timestamp |
| `Invalid user_ids format` | Push/email: not a JSON array string |
### Common `401` / `403`
| Situation | Result |
|-----------|--------|
| Missing/invalid Bearer token | `401` |
| Missing RBAC permission | `403` |
### Common `500`
| Situation | Result |
|-----------|--------|
| FCM not configured (push) | Push send fails |
| AfroMessage / Resend misconfigured | SMS/email failures |
| DB error creating scheduled row | Schedule request fails |
### Partial delivery
For immediate sends, the API returns `sent` and `failed` counts. The HTTP status is still `200` if the request was processed.
For scheduled jobs, if **at least one** recipient succeeds, status becomes `sent`. If **all** fail, status becomes `failed` and `last_error` is set.
---
## Operational notes & limitations
| Topic | Detail |
|-------|--------|
| Scheduler interval | ~30 seconds (not exact-to-the-second) |
| Scheduled push images | Not supported — image upload only on immediate push |
| Scheduled email attachments | Not supported — attachments only on immediate send |
| Scheduled job retry | No automatic retry; failed jobs stay `failed` |
| Edit / reschedule | Not supported — cancel and create a new job |
| Multi-instance | Safe via DB row locking |
| SMS provider | AfroMessage |
| Email provider | Resend (messenger service) |
| Push provider | Firebase Cloud Messaging |
| History records | Immediate SMS/email/push call `RecordNotification` for known `user_ids`; scheduled SMS/email/push do **not** write per-user history rows. In-app always creates notification rows (immediate and scheduled). |
| Migration | `000083` required for `in_app` scheduled channel |
---
## Admin UI integration checklist
### Compose screen (per channel)
- [ ] Title/subject + message fields (channel-specific)
- [ ] Recipient picker: user multi-select, role dropdown, and/or direct phones/emails
- [ ] Toggle: **Send now** vs **Schedule**
- [ ] Date/time picker → serialize as RFC3339 UTC (`scheduled_at`)
- [ ] Optional: image upload (push immediate only), email attachment (immediate only)
- [ ] Optional: in-app `type` and `level` selectors
### After submit
- [ ] Immediate: show `sent` / `failed` / `total_recipients` from `data`
- [ ] Scheduled: show returned `id`, `scheduled_at`, `status` — link to job detail view
### Scheduled jobs list
- [ ] Call `GET /notifications/scheduled` with filters (`status`, `channel`, date range)
- [ ] Paginate with `page` + `limit`
- [ ] Show status badges: pending → processing → sent / failed / cancelled
- [ ] Cancel button → `POST /notifications/scheduled/:id/cancel` for `pending`/`processing`
### Learner app (in-app only)
- [ ] WebSocket connect on login with `?token=`
- [ ] Handle `CREATED_NOTIFICATION` events
- [ ] Fallback poll `GET /notifications` and `GET /notifications/unread`
---
## Quick reference — all endpoints
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/api/v1/notifications/bulk-sms` | Bulk / schedule SMS |
| `POST` | `/api/v1/notifications/bulk-email` | Bulk / schedule email |
| `POST` | `/api/v1/notifications/bulk-push` | Bulk / schedule push |
| `POST` | `/api/v1/notifications/bulk-in-app` | Bulk / schedule in-app |
| `GET` | `/api/v1/notifications/scheduled` | List scheduled jobs |
| `GET` | `/api/v1/notifications/scheduled/:id` | Get scheduled job |
| `POST` | `/api/v1/notifications/scheduled/:id/cancel` | Cancel scheduled job |
| `GET` | `/api/v1/ws/connect?token=` | In-app WebSocket |
| `GET` | `/api/v1/notifications` | User notification inbox |

View File

@ -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 {

View File

@ -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)
// }

View File

@ -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)

View File

@ -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

View File

@ -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)