Broadcast (admin) — Submit, queue, approve, reject
Four admin endpoints for sending platform-wide or segmented broadcasts (e.g. system_maintenance, marketing_campaign). Behind a two-person approval gate in production. Submit → pending queue → second admin approves or rejects.
/api/v1/admin/notifications/broadcast* — 👤 Admin · permission notification:manage
Wave F-3 (shipped 2026-06-22) added a two-person approval gate for broadcast sends. When the notification.broadcast_dual_approval flag is TRUE (production default), every broadcast lands in NotificationBroadcastRequest as PENDING and a second admin must call the approve endpoint to fire the segment fan-out. Same-admin guard: the requester cannot approve or reject their own pending request.
Response shape varies by flag. When notification.broadcast_dual_approval is TRUE, POST /broadcast returns { requestId, status: 'PENDING' }. When FALSE (dev/staging), it falls back to the legacy direct-send path and returns { sent, total, segment, eventKey }. Clients should narrow on the presence of requestId vs sent. See BroadcastRequestResponseDto.
Approval pattern mirrors PayoutRequest. Atomic flip via updateMany WHERE requesterId != approverId — race-safe against double-approve and self-approval (apps/api-core/src/modules/payout/payout.service.ts:147-200 is the source pattern). PENDING requests older than retention.broadcast_request_ttl_hours (default 24h) are swept to EXPIRED by the daily retention cron.
POST /admin/notifications/broadcast — submit
Submits a broadcast for the configured segment. Returns either a pending request ID (flag ON) or direct fan-out result (flag OFF).
Request body — RequestBroadcastDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
eventKey | string | ✓ | IsString, IsNotEmpty, MaxLength(128) | Must exist in NOTIFICATION_EVENTS. Common: marketing_campaign, system_maintenance, broadcast_approved. |
segment | enum | ✓ | IsIn(['all', 'creators', 'fans']) | all = active users (cap 10k); creators = active users with a CreatorProfile; fans = active users without a CreatorProfile. |
variables | object | — | IsObject | Template variables interpolated into subject / body. Stringified at send time. |
channels | object | — | nested BroadcastChannelMaskDto | { email?, push?, inApp?, sms? } booleans. Omitted flags default false except inApp which defaults true. |
scheduledAt | string | — | IsISO8601 | Future ISO timestamp. Past or null fires immediately. Propagated onto every per-user NotificationJob.scheduleAt. |
Throttle / permission
| Layer | Limit |
|---|---|
| Permission | notification:manage |
| Throttle | 5 req / hour |
| HTTP code | 200 |
Response — flag ON
{
"success": true,
"data": {
"requestId": "n-broadcast-uuid-here",
"status": "PENDING"
}
}Response — flag OFF (dev / staging)
{
"success": true,
"data": {
"sent": 4823,
"total": 4823,
"segment": "all",
"eventKey": "system_maintenance"
}
}Errors
| HTTP | Reason |
|---|---|
400 | Invalid payload (bad segment enum, missing eventKey, etc.) |
401 | Missing / invalid admin session |
403 | Permission denied (notification:manage required) |
GET /admin/notifications/broadcast/pending — pending queue
Returns up to 100 NotificationBroadcastRequest rows in PENDING status, oldest first. Powers the admin queue view for second-admin sign-off.
Response — ArrayApiResponseOf<PendingBroadcastItemDto>
{
"success": true,
"data": [
{
"id": "n-broadcast-uuid-here",
"eventKey": "marketing_campaign",
"segment": "fans",
"channels": { "email": true, "push": false, "inApp": true, "sms": false },
"variables": { "campaignName": "Spring 2026" },
"scheduledAt": null,
"requesterId": "admin-uuid",
"requestedAt": "2026-06-22T14:00:00.000Z"
}
]
}| Field | Type | Notes |
|---|---|---|
id | string (UUID) | NotificationBroadcastRequest.id — used in the approve / reject path |
eventKey | string | From submit payload |
segment | enum | 'all' | 'creators' | 'fans' |
channels | object | Channel mask submitted by the requester |
variables | object | null | Template variables submitted by the requester |
scheduledAt | string | null | ISO 8601 if scheduled, null for immediate-fire |
requesterId | string (UUID) | Admin who created the request — same-admin guard uses this |
requestedAt | string (ISO 8601) | Creation timestamp |
Errors
| HTTP | Reason |
|---|---|
401 | Missing / invalid admin session |
403 | Permission denied (read scope = class-level notification:read) |
POST /admin/notifications/broadcast/:id/approve
Second admin approves a pending broadcast. Atomic updateMany WHERE requesterId != approverId AND status = PENDING prevents self-approval and double-processing. On success, segment fan-out runs inline and NotificationBroadcastRequest.status flips PENDING → SENT.
Throttle / permission
| Layer | Limit |
|---|---|
| Permission | notification:manage |
| Throttle | 20 req / hour |
| HTTP code | 200 |
Response — BroadcastApprovalResponseDto
{
"success": true,
"data": {
"requestId": "n-broadcast-uuid-here",
"status": "SENT",
"sent": 4823,
"total": 4823,
"segment": "fans",
"eventKey": "marketing_campaign"
}
}Errors
| HTTP | Reason |
|---|---|
400 | Cannot self-approve OR already processed (admin.notification.broadcast_self_approve / broadcast_invalid_status) |
401 | Missing / invalid admin session |
403 | Permission denied (notification:manage required) |
404 | Broadcast request not found |
Side effects
- Atomic flip
PENDING → APPROVEDwithapproverId+approvedAtset (race-safe). - If matched row is 0, disambiguates:
findUnique→ 404 (missing) / 400 self-approve / 400 wrong-status. - Load the now-APPROVED row, fan out via
fanOutBroadcastusingrequestIdas the dedupe seed (stable across the request → approve gap — re-approve never double-sends). scheduledAtin the future is propagated onto every per-userNotificationJob.scheduleAtso the worker poll respects the slot.- Flip
APPROVED → SENTwithsentAttimestamp. - Fire
broadcast_approvedin-app notification to the requester (fire-and-forget, no email).
POST /admin/notifications/broadcast/:id/reject
Second admin rejects a pending broadcast. reason is required (10–500 chars) and persisted on the request row for audit.
Request body — RejectBroadcastDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
reason | string | ✓ | MinLength(10), MaxLength(500) | Persisted as rejectionReason. Trimmed and clipped to 500 chars server-side. |
Throttle / permission
| Layer | Limit |
|---|---|
| Permission | notification:manage |
| Throttle | 20 req / hour |
| HTTP code | 200 |
Response — BroadcastRejectionResponseDto
{
"success": true,
"data": {
"requestId": "n-broadcast-uuid-here",
"status": "REJECTED"
}
}Errors
| HTTP | Reason |
|---|---|
400 | Reason empty / too short / already processed / self-rejection attempted |
401 | Missing / invalid admin session |
403 | Permission denied (notification:manage required) |
404 | Broadcast request not found |
Side effects
- Validate reason (non-empty after trim —
400if blank, length validators run at DTO level). - Atomic flip
PENDING → REJECTEDwithapproverId,approvedAt,rejectionReasonset (race-safe; same-admin guard). - If matched is 0, disambiguates: 404 (missing) / 400 self-rejection / 400 wrong-status.
- No notification dispatch to the requester (advisor scope-cut for F3).
Source
| Source | Path | Lines |
|---|---|---|
Controller (POST /broadcast) | apps/api-core/src/modules/notification/admin-notification.controller.ts | 645–675 |
Controller (GET /broadcast/pending) | apps/api-core/src/modules/notification/admin-notification.controller.ts | 677–692 |
Controller (POST /broadcast/:id/approve) | apps/api-core/src/modules/notification/admin-notification.controller.ts | 694–717 |
Controller (POST /broadcast/:id/reject) | apps/api-core/src/modules/notification/admin-notification.controller.ts | 719–743 |
| Request DTO (submit) | apps/api-core/src/modules/notification/dto/admin-notification.dto.ts | 275–309 (RequestBroadcastDto) |
| Request DTO (reject) | apps/api-core/src/modules/notification/dto/admin-notification.dto.ts | 315–325 (RejectBroadcastDto) |
| Response DTO (submit) | apps/api-core/src/modules/notification/dto/admin-notification-response.dto.ts | 358–367 (BroadcastRequestResponseDto) |
| Response DTO (approve) | apps/api-core/src/modules/notification/dto/admin-notification-response.dto.ts | 375–393 (BroadcastApprovalResponseDto) |
| Response DTO (reject) | apps/api-core/src/modules/notification/dto/admin-notification-response.dto.ts | 399–405 (BroadcastRejectionResponseDto) |
| Response DTO (pending item) | apps/api-core/src/modules/notification/dto/admin-notification-response.dto.ts | 410–441 (PendingBroadcastItemDto) |
Service (broadcast router) | apps/api-core/src/modules/notification/admin-notification.service.ts | 1335–1364 |
Service (approveBroadcast) | apps/api-core/src/modules/notification/admin-notification.service.ts | 1066–1198 |
Service (rejectBroadcast) | apps/api-core/src/modules/notification/admin-notification.service.ts | 1206–1256 |
Service (listPendingBroadcasts) | apps/api-core/src/modules/notification/admin-notification.service.ts | 1263–1293 |
Service (expireStaleBroadcasts — cron sweep) | apps/api-core/src/modules/notification/admin-notification.service.ts | 1305–1324 |
| Prisma model | packages/prisma/prisma/schema.prisma | NotificationBroadcastRequest (2259), BroadcastRequestStatus enum (2280) |
| Migration | packages/prisma/prisma/migrations/20260622000002_notification_broadcast_request/migration.sql | (model + enum + indexes) |
| Live response | NOT verified — sourced from DTOs cited above |
Observability — NotificationJob columns
How latencyMs + costUsd are populated on NotificationJob rows (Wave E Batch 2). Covers when workers write them, what queries consume them, and why costUsd remains null today.
Spectrum Smoke (admin) — End-to-end dispatch test
Fires one notification per event in SPECTRUM_SMOKE_EVENTS (26 events default) to a single recipient. Useful after a template change to verify rendering + provider delivery end-to-end. Channel overrides force email-only by default; admin can opt into push / web-push for FCM credential testing.