Skip to content

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, default gen_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 (enum message_target_type: ALL, ACTIVITY, SESSION, MANUAL).
  • targetId (nullable uuid) — activityId or sessionId; NULL for ALL and MANUAL. 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, sentBy ON DELETE CASCADE (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/notifications.schema.ts).
  • companyId, sentBy, text, targetType NOT NULL (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/notifications.schema.ts).
  • targetId is a polymorphic id — its target table (activities.activities for ACTIVITY, activities.sessions for SESSION, NULL for ALL and MANUAL) depends on targetType. 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.send returns, neither the Message nor its fan-out rows are edited.
  • Fan-out skips offline customersMessagesService.send iterates customers and only calls notify when userId is set. Offline customers are not represented in messageRecipients either, 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.
  • targetId polymorphic per targetTypeACTIVITY / SESSION carry the matching id; ALL / MANUAL are 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:

  1. Resolve recipients based on (targetType, targetId, customerIds) — produces a list of customers (with their userId if any).
  2. Assert subscription messages limitSubscriptionService.assertMessagesLimit(companyId, recipientCount). Reject if exceeded.
  3. Insert the Message row.
  4. Fan-out (Promise.all over recipients): for each customer with userId, call notifications.notify({type:'BROADCAST_MESSAGE', ...}), then INSERT a message_recipient row with notificationId set to the new notification (or NULL if notify failed).
  5. Return the message + recipient count.

Files: libs/features/notifications/src/lib/services/messages.service.ts.

Relationships

  • Company (ENT-016) — companyIdcompanies.company.id, on-delete cascade. N:1.
  • User (sender) (ENT-021) — sentByusers.users.id, on-delete cascade. N:1.
  • Message recipient (ENT-025) — 1:N child rows, one per delivered recipient.

API surfaces

SurfaceExposedNotes
clientno
businessinferred yes — managed via libs/features/notifications/src/lib/messages-admin.module.ts; no dedicated Message* DTO visible in audit-time business OpenAPI extractSwagger UI
super-adminno

Known gotchas / open questions

  • targetId is a polymorphic id without DB-level FK — readers must branch on targetType (ACTIVITYactivities.activities.id, SESSIONactivities.sessions.id, ALL / MANUALNULL). See ADR cross-schema-references-without-fk.
  • The BROADCAST_MESSAGE notification type on the user side is generated for each message recipient via Message recipientNotification.
  • Fan-out is NOT transactional. The Promise.all loop 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 no message_recipient row 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”.
  • targetId survives 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-outPromise.all on 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.transaction OR 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_recipient with a status field (SENT / SKIPPED_OFFLINE / FAILED) so delivery stats and admin UI can show what actually happened.
  • Bill only for delivered: assertMessagesLimit should count online recipients (with userId), not all resolved customers. Otherwise companies pay for ghosts.
  • DB CHECK on (targetType, targetId) consistency — e.g. trigger or CHECK ((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.