Message
Purpose
A broadcast initiated by a Company member — one row per broadcast — that fans out into many Notification rows (one per online customer). targetType decides who the broadcast reaches: ALL customers of the company, just those attached to a specific ACTIVITY or SESSION, or a MANUAL hand-picked list.
Identity & key fields
- Primary key:
id(uuid, defaultgen_random_uuid()). companyId(uuid, NOT NULL, FK →companies.company.id, on-delete cascade).sentBy(uuid, NOT NULL, FK →users.users.id, on-delete cascade) — the user who initiated the broadcast.text(text, NOT NULL) — body.targetType(enummessage_target_type:ALL,ACTIVITY,SESSION,MANUAL).targetId(nullable uuid) —activityIdorsessionId; NULL forALLandMANUAL. No DB-level FK declared.
A single Message row is the broadcast record kept for the company’s history; each recipient gets a separate Message recipient row plus a BROADCAST_MESSAGE Notification in their inbox. Offline customers (no linked userId) receive nothing — they are silently skipped.
Invariants
companyId,sentByON DELETE CASCADE (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/notifications.schema.ts).companyId,sentBy,text,targetTypeNOT NULL (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/notifications.schema.ts).targetIdis a polymorphic id — its target table (activities.activitiesforACTIVITY,activities.sessionsforSESSION,NULLforALLandMANUAL) depends ontargetType. No DB-level FK by design — polymorphic refs cannot be FK-constrained. See ADR cross-schema-references-without-fk.
Business invariants:
- Write-once row — after
MessagesService.sendreturns, neither the Message nor its fan-out rows are edited. - Fan-out skips offline customers —
MessagesService.senditeratescustomersand only callsnotifywhenuserIdis set. Offline customers are not represented inmessageRecipientseither, so they’re invisible to delivery stats. - Subscription billing gate:
SubscriptionService.assertMessagesLimit(companyId, count)runs before the fan-out and rejects if the plan’s message quota is exceeded. The count includes resolved customers regardless of online/offline status. targetIdpolymorphic pertargetType—ACTIVITY/SESSIONcarry the matching id;ALL/MANUALare NULL. No DB FK by design (polymorphic refs).- Cascade on Company / sender User delete — every broadcast row tied to a deleted company or sender vanishes.
Lifecycle
No status enum — the row is write-once. The lifecycle is contained inside the single MessagesService.send operation:
- Resolve recipients based on
(targetType, targetId, customerIds)— produces a list of customers (with theiruserIdif any). - Assert subscription messages limit —
SubscriptionService.assertMessagesLimit(companyId, recipientCount). Reject if exceeded. - Insert the Message row.
- Fan-out (
Promise.allover recipients): for each customer withuserId, callnotifications.notify({type:'BROADCAST_MESSAGE', ...}), then INSERT amessage_recipientrow withnotificationIdset to the new notification (or NULL ifnotifyfailed). - Return the message + recipient count.
Files: libs/features/notifications/src/lib/services/messages.service.ts.
Relationships
- Company (ENT-016) —
companyId→companies.company.id, on-delete cascade. N:1. - User (sender) (ENT-021) —
sentBy→users.users.id, on-delete cascade. N:1. - Message recipient (ENT-025) — 1:N child rows, one per delivered recipient.
API surfaces
| Surface | Exposed | Notes |
|---|---|---|
| client | no | — |
| business | inferred yes — managed via libs/features/notifications/src/lib/messages-admin.module.ts; no dedicated Message* DTO visible in audit-time business OpenAPI extract | Swagger UI |
| super-admin | no | — |
Known gotchas / open questions
targetIdis a polymorphic id without DB-level FK — readers must branch ontargetType(ACTIVITY→activities.activities.id,SESSION→activities.sessions.id,ALL/MANUAL→NULL). See ADR cross-schema-references-without-fk.- The
BROADCAST_MESSAGEnotification type on the user side is generated for each message recipient via Message recipient → Notification. - Fan-out is NOT transactional. The
Promise.allloop over recipients runs after the Message row is committed. If the process crashes mid-loop, some recipients are notified, others are not — and a retry produces duplicates because there is no UNIQUE on(messageId, customerId). - Offline customers are silently skipped. They are counted by
assertMessagesLimit(so the company pays for them), but nomessage_recipientrow is produced and they never see the broadcast. The admin UI has no signal to tell them “20% of your audience didn’t receive it”. targetIdsurvives target deletion — broadcasting to a session that is later deleted leaves a Message row pointing at a non-existent id. No cleanup runs.- Synchronous fan-out —
Promise.allon a large recipient list is a long-running HTTP request. For 10k+ customers this is a timeout risk.
Recommendations
Forward-looking improvements suggested while filling this doc — not currently in place.
- Wrap fan-out in
db.transactionOR introduce idempotency: a UNIQUE on(message_id, customer_id)and retry-safe insert (ON CONFLICT DO NOTHING). Either approach removes the partial-fan-out problem. - Track offline recipients explicitly — extend
message_recipientwith astatusfield (SENT/SKIPPED_OFFLINE/FAILED) so delivery stats and admin UI can show what actually happened. - Bill only for delivered:
assertMessagesLimitshould count online recipients (withuserId), not all resolved customers. Otherwise companies pay for ghosts. - DB CHECK on
(targetType, targetId)consistency — e.g. trigger orCHECK ((target_type IN ('ALL','MANUAL') AND target_id IS NULL) OR (target_type IN ('ACTIVITY','SESSION') AND target_id IS NOT NULL)). - Asynchronous fan-out via BullMQ — for large recipient sets, return the Message row immediately and let a queue worker handle the per-recipient inserts. The current synchronous Promise.all is a scalability risk.
- Broadcast quality dashboard — % delivered, % read per Message; today the JOIN is possible but not surfaced in the admin UI.