Communication (Chat, Notifications, Calls, Announcements, Email)
Part of the Anaya Care product wiki. See 00-overview.md.
Purpose
This module is the platform's real-time and asynchronous communication layer:
- Chat — 1:1 ("private") and group conversations with attachments, replies, reactions, read/delivery receipts, soft message deletion, and media galleries (
apps/backend/src/chat/). - Calls — audio/video calls inside private conversations, signaled over the chat WebSocket and carried over LiveKit WebRTC rooms (
apps/backend/src/chat/call.service.ts,apps/backend/src/livekit/). - Notifications — a unified, registry-driven fan-out hub. Domain modules emit events; listeners resolve recipients; a BullMQ queue dispatches to four channels: in-app (DB + WebSocket), Expo push, Resend email, Twilio SMS (
apps/backend/src/notification/). - Announcements — business-wide bulletins with priorities, scheduling, acknowledgment tracking, and deadline reminders (
apps/backend/src/announcements/). - Email — React Email templates sent through Resend, with delivery-status tracking via a Resend webhook (
apps/backend/src/emails/,apps/backend/src/common/services/email-sender.service.ts,apps/backend/src/app.controller.ts).
It uses the platform's dual WebSocket architecture: two separate Socket.IO namespaces, /chat (apps/backend/src/chat/chat.gateway.ts:56-59) and /notification (apps/backend/src/notification/notification.gateway.ts:16-19), both authenticated by the same middleware (apps/backend/src/websocket/websocket-auth.service.ts:35-49).
AI chat (the assistant in apps/mobile/app/(ai-chat) and apps/web/.../dashboard/ai-chat) is a separate feature — see 14-ai-features.md.
Entities & Data Model
Conversation — collection conversations (apps/backend/src/chat/entities/conversation.entity.ts)
| Field | Type | Notes |
|---|---|---|
business | ObjectId → Business | required, indexed (line 41-47) |
name | string | required; private conversations are hardcoded 'Private Conversation' (conversation.service.ts:202) |
image | string | group avatar |
type | enum ConversationType | GROUP (default) or PRIVATE (line 55-61) |
createdBy | ObjectId → User | required |
members | ObjectId[] → User | participant list |
lastMessage | ObjectId → Message | denormalized for sidebar |
lastMessageAt | Date | sort key for sidebar |
activeCall | embedded ActiveCall | {type, status, initiatedBy, startedAt, answeredAt} — present only while a call is ringing/active (lines 8-23, 75-85) |
Message — collection messages (apps/backend/src/chat/entities/message.entity.ts)
| Field | Type | Notes |
|---|---|---|
business | ObjectId → Business | required, indexed |
type | enum MessageType | default TEXT; CALL_LOG for inline call entries (chat.service.ts:127) |
content | string | optional if attachments present |
sender | ObjectId → User | required |
conversation | ObjectId → Conversation | |
callLog | ObjectId → CallLog | only for call-log messages |
deliveredTo / readBy | ObjectId[] → User | per-user receipts (unbounded arrays) |
attachments | {type, url, metadata, blurhash}[] | AttachmentType from interfaces/message.interface.ts |
replyTo | embedded {messageId, content, sender, createdAt} | snapshot, not a live ref |
reactions | {emoji, users[]}[] | |
isDeleted / deletedAt / deletedBy | soft delete | content replaced with "This message was deleted", attachments/reactions cleared (chat.service.ts:761-770) |
CallLog — collection call_logs (apps/backend/src/chat/entities/call-log.entity.ts)
| Field | Type | Notes |
|---|---|---|
business | ObjectId → Business | required, indexed |
conversationId | ObjectId → Conversation | indexed with createdAt |
callType | enum CallType | AUDIO / VIDEO |
status | enum CallStatus | ENDED, DECLINED, MISSED are persisted statuses |
initiatedBy / answeredBy | ObjectId → User | answeredBy only when answered |
startedAt / answeredAt / endedAt | Date | |
duration | number (seconds) | only for answered calls, measured from answeredAt (call.service.ts:205-208) |
endReason | string | 'ended', 'declined', 'no_answer', 'disconnected' |
Notification — collection notifications (apps/backend/src/notification/entities/notification.entity.ts)
| Field | Type | Notes |
|---|---|---|
userToNotify | ObjectId → User | required |
userWhoNotified | ObjectId → User | required; system notifications use a dedicated System user (channels/in-app.channel.ts:48-58) |
type | enum NotificationTypeType | 110 registered types (see registry) |
link | string | deep link (web /dashboard/... or mobile route) |
isRead / isDeleted | boolean | soft delete |
metadata | Mixed | template interpolation values |
imageUrl | string | rich push image |
| TTL | — | auto-deleted after 90 days via TTL index (line 67-69) |
There is no business field on notifications — tenancy is implied by the recipient.
NotificationPreferences — collection notification_preferences (apps/backend/src/notification/entities/notification-preferences.entity.ts)
| Field | Type | Notes |
|---|---|---|
user | ObjectId → User | unique |
postNotifications | {postLikes, postComments, commentReplies, commentLikes, mentions} | per-type toggles, only for community posts (commentLikes defaults false) |
enablePushNotifications / enableEmailNotifications / enableSMSNotifications | boolean | per-channel master switches, default true |
The per-user unread badge counter lives on the User document (unReadNotificationCount), incremented/decremented by the in-app channel and notification read/delete endpoints, and reconciled daily (see workflows).
Announcement — collection announcements (apps/backend/src/announcements/entities/announcement.entity.ts)
| Field | Type | Notes |
|---|---|---|
business | ObjectId → Business | required, indexed |
title / content | string | max 200 / 5000 chars |
priority | enum AnnouncementPriority | default MEDIUM; URGENT escalates push behavior |
status | enum AnnouncementStatus | DRAFT → SCHEDULED → PUBLISHED → PAST_DEADLINE |
attachments | image/video/document array | |
author | ObjectId → User | |
scheduledAt / deadlineAt / publishedAt | Date | |
reminderTimings | number[] (minutes before deadline) | drives reminder cron |
acknowledgementsCount | number | denormalized counter |
isDeleted / deletedAt / deletedBy | soft delete |
AnnouncementAcknowledgment — collection announcement_acknowledgments (apps/backend/src/announcements/entities/announcement-acknowledgment.entity.ts)
| Field | Type | Notes |
|---|---|---|
business | ObjectId → Business | required |
announcement | ObjectId → Announcement | unique compound index with user (lines 51-54) |
user | ObjectId → User | |
acknowledgedAt | Date |
Email tracking
There is no dedicated email collection. Resend delivery events update status fields on existing documents: care-proposal email status via careProposalsService.updateEmailStatus(email_id, status) and user-invite status via usersService.updateUserInviteStatus(email_id, status) (apps/backend/src/app.service.ts:28-44). Statuses: Sent, Delivered, Delivery Delayed, Complained, Bounced, Opened, Clicked (app.service.ts:8-16).
Workflows & State Machines
Chat: connection and rooms
- Client connects to the
/chatnamespace with a JWT inhandshake.auth.token; middleware validates token + token version and caches the user onsocket.data(websocket-auth.service.ts:35-83). - On connect, the socket joins a personal room
chat-user:{userId}and a tenant roombusiness:{businessId}; the userId→socketId mapping is stored in Redis (global hash plus per-business hash) and the business-scoped online-user list is broadcast (chat.gateway.ts:118-149,websocket-auth.service.ts:104-144). - Clients join conversation rooms (
conversation:{id}) viaJOIN_CONVERSATION/JOIN_CONVERSATIONS/SUBSCRIPTIONS_SYNC; each join performs a membership checkcanAccessConversation(member-of check, fail-closed on error) and auto-marks all messages delivered (chat.gateway.ts:491-791,chat.service.ts:795-847).
Chat: who can chat with whom
- Gate: every REST chat endpoint requires
BusinessPermission.CHAT_ACCESS(chat.controller.ts:51etc.). All default roles — Admin, CareProvider, Representative, MedicalProfessional — getCHAT_ACCESSandCALLS_INITIATE(packages/shared/src/constants/default-role-permissions.ts:127-128, 235-236, 304-305, 352-353). - 1:1:
CREATE_PRIVATE_CONVERSATION(WS) orPOST /conversations/private/:userIdcreates or reuses a 2-memberPRIVATEconversation. Dedup is by{type: PRIVATE, members: $all [a,b], $size: 2}— not filtered by business (conversation.service.ts:185-209). There is no server-side check that the two users belong to the same business; the conversation'sbusinessis the creator's. - Group: any user with
CHAT_ACCESScan create a group with arbitrary member ids (chat.gateway.ts:846-897,chat.controller.ts:50-69). Only the creator can rename or delete a group; SuperAdmin can delete any conversation (conversation.service.ts:129-183,chat.service.ts:544-585). The creator cannot be removed as a member (conversation.service.ts:70-72). - Message deletion: sender can soft-delete own messages; platform roles (SuperAdmin/System via
isPlatformRole) can moderate any (chat.service.ts:752-758). - Sending:
SEND_MESSAGEpersists the message, marks it read for the sender, broadcastsRECEIVE_MESSAGEto the conversation room, then emits per-memberCONVERSATION_UPDATEDwith that member's own unread count, and finally emits thechat.message-sentdomain event for push delivery to offline members (chat.gateway.ts:197-270). Note: the send handler does not re-verify conversation membership (only room joins do). - Unread counts are computed by aggregation over
messages(sender ≠ me,readBy ≠ me, not deleted) — no per-conversation counter document (chat.service.ts:294-309, 396-417).
Chat push (offline recipients only)
ChatNotificationListener (apps/backend/src/notification/listeners/chat-notification.listener.ts:29-103) handles chat.message-sent: it removes the sender, filters out users currently in the Redis connected-clients hash, and sends a ChatMessageReceived unified notification with skipInApp: true (chat has its own in-app surface) and a custom push payload (sender as title, 100-char preview as body, deep link /(app)/(chat)/{conversationId}). The ChatMessageReceived registry entry is pushOnly (notification-registry.ts:42-48).
Call flow (LiveKit)
Calls are private-conversation-only, max 2 participants (call.service.ts:75-77, livekit.service.ts:76). Active-call state is the embedded activeCall on the conversation; persisted history is call_logs plus an inline CALL_LOG chat message.
Details: ring timeout is an in-memory setTimeout; stuck RINGING calls are swept to MISSED on server startup and stale activeCalls older than timeout+10s are auto-cleared on the next initiate (call.service.ts:35-54, 79-92). A socket disconnect only ends the user's calls when they have zero remaining sockets in their personal room (multi-device safe; chat.gateway.ts:151-195, 1356-1393). LiveKit credentials default to devkey/devsecret/ws://localhost:7880 when env vars are unset (livekit.service.ts:14-16).
Notification pipeline (the fan-out hub)
The canonical path: domain event → listener (recipient resolution) → NotificationService.sendUnifiedNotification → BullMQ notification queue (unified-notification job, 3 attempts, exponential backoff) → NotificationProcessor → NotificationDispatcherService.dispatch → channels (notification.service.ts:416-466, notification.processor.ts:495-562, notification-dispatcher.service.ts:36-156).
Channel eligibility per recipient (notification-dispatcher.service.ts:101-132, 173-207):
- Registry definition must enable the channel (
NOTIFICATION_REGISTRY, 110 types across 20 categories; presetsstandard,highPriority,critical(push+email+SMS),withEmail(templateId),pushOnly,silentPush—notification-registry.ts:8-61). - Global config flags (
notification.enableInAppNotification|enablePushNotification|enableEmailNotification|enableSMSNotification). - Email/SMS only go to
isActive !== falseusers. - If the definition is
userConfigurable, per-user preferences are consulted (shouldSendForChannel: per-type post toggles + per-channel master switch; in-app is never user-suppressible —notification-preferences.service.ts:239-261). skipInApppayload flag suppresses the in-app channel (used by chat/call pushes).
In-app channel specifics (channels/in-app.channel.ts): system-originated notifications (userWhoNotifiedId null/'system') are attributed to a System user (created on demand); each DB write increments User.unReadNotificationCount with a compensating rollback (notification deleted if the counter update fails); successful writes are pushed over the /notification gateway as NOTIFICATION_SENT to room user-{userId} with the new unread count (notification.gateway.ts:100-144). Counters are reconciled by a daily 2 AM cron plus admin endpoints (counter-reconciliation.service.ts:25-37, notification.controller.ts: counter/*).
Push specifics (channels/push.channel.ts, expo-notification.service.ts): Expo SDK with FCMv1 toggle, chunked sends, receipt processing after a delay, and automatic removal of invalid tokens (DeviceNotRegistered etc., expo-notification.service.ts:401-533). Batches >50 users are re-queued as batch-push jobs. Android channel + iOS thread ids come from resolveNotificationThread in @anaya/shared.
Other queue job types (notification.processor.ts:77-90): care-proposal (status-based recipient resolution with reviewer-targeted links, see handleCareProposalNotification lines 100-363), shift-response (notifies all BUSINESS_MANAGEMENT_ROLES managers), scheduled-push, batch-push.
Specialized senders that compose onto the same pipeline: ShiftNotificationService (assignment, response, reassignment, cancellation, modification, pickup, clock events, bulk ops, reflection reminders — shift-notification.service.ts), PostNotificationService (Redis-batched like notifications, 5-min window, max 10 per batch — post-notification.service.ts:34-41).
Announcement lifecycle
- Create/update/publish/delete require
ANNOUNCEMENTS_MANAGE; stats and the admin list requireANNOUNCEMENTS_VIEW; the feed, counts, detail, and acknowledge endpoints are open to all authenticated users in the business (announcements.controller.ts:45-181). - On publish,
announcement.publishedis emitted (announcements.service.ts:968-1001);AnnouncementNotificationServicesends anAnnouncementPublishedunified notification to all active users excluding the author — the recipient query is{isActive: true}with no business filter (announcement-notification.service.ts:106-114). Priority shapes the push (URGENT → high priority,time-sensitiveinterruption, 🚨 title). - Crons (
tasks/announcement.tasks.ts): publish dueSCHEDULEDannouncements every minute; markPAST_DEADLINEevery 5 minutes. - Deadline reminders (
services/announcement-monitoring.service.ts:50-157): every 5 minutes, for published announcements withdeadlineAtwithin 24h andreminderTimings, unacknowledged active users of the announcement's business get anAnnouncementDeadlineReminder(sent as the System user), deduplicated per (announcement, timing, user) via Redis keys with a 24h TTL (services/announcement-reminder-tracking.service.ts). - Acknowledgment: allowed on
PUBLISHEDandPAST_DEADLINE; idempotent (unique index + early return); incrementsacknowledgementsCount(announcements.service.ts:654-712).
Transactional email (Resend) & webhooks
- All email flows through
EmailSenderService(apps/backend/src/common/services/email-sender.service.ts): sliding-window rate limit of 8/sec with 429 retry/backoff;sendTransactional(auth/OTP/password flows — bypasses the feature flag) vssendOperational(notification-channel and domain emails — respectsENABLE_EMAIL_NOTIFICATION). Subjects are prefixed[STAGING]in staging (doSend, line 121;common/utils/runtime-environment.ts:26-32). - Rich domain templates are React Email components in
apps/backend/src/emails/(EmailVerification, MagicLink, PasswordResetRequest/Success, InviteUserEmail, RepresentativeWelcomeEmail, CareProposalEmail/Revised, IdVerification Submitted/Approved/Rejected, Admin alerts/digests) and are sent directly by their owning services — not through the notification dispatcher (channels/email-template.resolver.tsx:40-42). - Notification-channel emails use one generic branded layout (interpolated title/body + optional CTA whose relative link is absolutized with
FRONTEND_URL); the registrytemplateIdis currently ignored by the resolver beyond gating (email-template.resolver.tsx:49-62). From-address:Anaya Care <donotreply@anayacare.com>(channels/email.channel.ts:10). - Webhook:
POST /webhook/resendverifies the Svix signature (HMAC overid.timestamp.rawBody, 5-minute replay window) only whenRESEND_WEBHOOK_SECRETis configured; otherwise events are accepted unauthenticated (app.controller.ts:29-73). Events map to email statuses and update care-proposal / user-invite records matched by subject prefix ("Care Proposal for…", "You have been invited to join Anaya Care…") (app.service.ts:28-83). - SMS goes through
SmsSenderService(Twilio;sendOperationalrespectsENABLE_SMS_NOTIFICATION; Twilio Verify for OTP; dev bypass code ++1555010XXXXtest numbers —common/services/sms-sender.service.ts:44-75).
Business Rules & Constraints
- Conversations, messages, call logs, announcements, and acknowledgments are tenant-scoped via a required
businessfield;Notificationdocuments are not (recipient-scoped only) (entities/*.ts). - Only
PRIVATEconversations support calls; one active call per conversation; LiveKit rooms cap at 2 participants and auto-close 60s after the last participant leaves (call.service.ts:75-91,livekit.service.ts:71-84). - Ring timeout is 45 seconds (
packages/shared/src/constants/call.constants.ts:2); LiveKit access tokens expire after 10 minutes (livekit.service.ts:49). - Group deletion: creator or SuperAdmin only; private conversation deletion: any member; deleting a conversation hard-deletes all its messages (
chat.service.ts:544-602). - Message deletion is soft and irreversible in UI terms — content replaced, attachments/reactions wiped; only the sender or a platform role may delete (
chat.service.ts:752-770). - A message must contain content or at least one attachment (
chat.service.ts:48-53). - Notifications expire automatically after 90 days (TTL index,
notification.entity.ts:67-69). - Chat pushes go only to recipients with no live WebSocket connection; in-app notification rows are never created for chat (
skipInApp,chat-notification.listener.ts:42-56, 78). - Email/SMS channels skip inactive users; the email channel additionally requires a registry
templateIdand a recipient email on file (notification-dispatcher.service.ts:112-115,email.channel.ts:36-50). - User preferences only suppress
userConfigurablenotification types; in-app delivery cannot be turned off per user (notification-dispatcher.service.ts:173-187,notification-preferences.service.ts:249-260). - Announcements: title ≤ 200 chars, content ≤ 5000; acknowledge only
PUBLISHED/PAST_DEADLINE; one acknowledgment per user per announcement (unique index) (announcement.entity.ts:49-53,announcements.service.ts:665-688). - Marking a notification read decrements the user counter only if it was unread and the counter is > 0;
mark-all-as-readzeroes the counter (notification.service.ts:218-288). - Push tokens are stored as
User.expoPushTokens[]; invalid tokens are pruned automatically from Expo receipts/tickets (expo-notification.service.ts:467-533).
Surfaces (Web & Mobile)
Web (apps/web — Admin/Owner/CareManager dashboard)
- Socket bootstrap:
components/client-layout.tsx:159-260opens both namespaces (${NEXT_PUBLIC_BACKEND_URL}/chatand/notification) once a session token exists; tracks online users, hydrates the conversation store for the sidebar unread badge, plays a chat sound for new messages in non-active conversations (frontend-only rule), and onNOTIFICATION_SENTinvalidates React Query caches (includingcare-proposalsfor care-proposal types), plays a notification sound, and shows a toast. - Chat UI:
app/(app)/(admin)/dashboard/chatandchat/[chatId]with components incomponents/chat/(chat-context.tsx,chat-panel.tsx,use-chat-socket-manager.tsx). Calls are supported viahooks/use-livekit-call.ts. - Notifications: dropdown in the header (
components/notification-dropdown.tsx), preference modal (components/user-profile/modals/notification-preferences-modal.tsx). - Announcements: admin CRUD at
app/(app)/(admin)/dashboard/announcements(list,add,[id],[id]/edit) — gated server-side byANNOUNCEMENTS_MANAGE/ANNOUNCEMENTS_VIEWpermissions.
Mobile (apps/mobile — CareProviders, Representatives/Families)
- Socket bootstrap:
contexts/socket-context.tsx:58-70opens both namespaces with the auth token; global listeners update the chat store, play sounds (suppressed for own messages and the active conversation — frontend-only), and handleCALL_RINGING/CALL_STATUS_CHANGEDvia a Zustand call store (ignores own calls and concurrent ringing — frontend-only, lines 122-140). - Chat screens: list/threads at
app/(app)/(tabs)/chat/and full conversation experience atapp/(conversation)/[id]/—index(messages),call,incoming-call,media,members,settings. - Announcements: feed with All/Unread/priority filters at
app/(announcements)/index.tsx(usesunacknowledgedOnlyparam) and detail/acknowledge at[announcementId].tsx. - Notifications: list + settings at
app/(app)/(tabs)/notifications/andapp/(shared)/notifications/. - Push setup:
services/notificationService.tsrequests permissions, registers Android channels fromconstants/notification-channels, and sets a foreground handler that suppresses chat pushes for the currently open conversation (lines 8-32). Tokens are synced toPOST /users/:id/expo-push-tokenand removed on logout (lib/push-token-api.ts). Tap handling (contexts/notification-context.tsx:81-116):incoming_calldata hydrates the call store and navigates to the incoming-call screen;jobPostingIddeep-links to the job; a genericdata.screenbranch exists but is empty (no-op) (lines 114-115). - AI chat lives at
app/(ai-chat)— documented in 14-ai-features.md.
Cross-Module Dependencies
This module is the fan-out hub. Producers and their entry points into the pipeline:
| Producer module | Mechanism | Events / calls | Listener / handler |
|---|---|---|---|
Care proposals (src/care-proposals) → 03-care-proposals.md | event | care-proposal.status-changed, care-proposal.authorship-transferred | listeners/notification.listener.ts:45-93 → queued care-proposal job (notification.processor.ts:100-363) |
Clients / caregiver assignments (src/clients) → 01-clients.md | event | caregiver.invitation-sent, caregiver.status-updated, caregiver.removed, careprovider.separated, careprovider.separation-scheduled, careprovider.reinstated | listeners/notification.listener.ts:95-302 |
Incident reports (src/incident-reports) → 09-incidents-and-alerts.md | event | incident-report.submitted, incident-report.status-changed, incident-report.shared-with-family | listeners/notification.listener.ts:306-410 (permission-based recipients: INCIDENT_REPORTS_VIEW_ALL) |
Emergency alerts (src/emergency-alerts) → 09-incidents-and-alerts.md | event | EMERGENCY_ALERT_EVENT | listeners/emergency-alert-notification.listener.ts (role-based recipients: MANAGEMENT_ROLES + family reps) |
Requests (src/requests) | event | request.created, request.status-changed, request.comment-added | listeners/request-notification.listener.ts (MANAGEMENT_ROLES) |
| Medication inventory | event | THRESHOLD_BREACH_EVENT, MEDICATION_TASK_COMPLETED_EVENT | listeners/threshold-breach-notification.listener.ts, listeners/medication-stock-notification.listener.ts (MANAGEMENT_ROLES) |
| Wound care | event | wound-care.assessment-created, wound-care.analysis-completed, wound-care.condition-worsening | listeners/wound-care-notification.listener.ts (BUSINESS_MANAGEMENT_ROLES) |
Users (src/users) | event | user.created | listeners/user-created.listener.ts |
| Chat (this module) | event | chat.message-sent, chat.incoming-call, chat.missed-call | listeners/chat-notification.listener.ts, listeners/call-notification.listener.ts |
| Announcements (this module) | event | announcement.published | announcement-notification.service.ts:20 |
Shifts (src/shifts, src/shift-handovers) → 05-scheduling-and-shifts.md | direct call | ShiftNotificationService.*, queueShiftResponseNotification, sendUnifiedNotification | shifts.service.ts, shifts.processor.ts, shift-handovers.service.ts |
Job postings (src/job-postings) → 06-job-postings.md | direct call | sendUnifiedNotification | services/job-postings.service.ts, services/job-applications.service.ts |
| Health observations, appointments, task comments, birthdays, posts | direct call | sendUnifiedNotification / PostNotificationService | health-observations.service.ts, clients/services/appointment-monitoring.service.ts, clients/services/task-submission-comments.service.ts, posts/tasks/birthday.tasks.ts |
| AI orchestrators (care plans, meals, tasks) → 14-ai-features.md | direct call + WS progress | sendUnifiedNotification; NotificationGateway.emitCarePlanProgress / emitCareProviderTasksProgress (notification.gateway.ts:186-213) | ai/agents/*-orchestrator.service.ts, clients/processors/* |
Open Questions & Gaps
- Cross-tenant announcement fan-out.
AnnouncementNotificationService.getAllActiveUserIdsqueries{isActive: true}with no business filter, so publishing an announcement notifies every active user on the platform, across all tenants (announcement-notification.service.ts:106-114; duplicate helper atannouncements.service.ts:956-964). The deadline-reminder path is business-scoped (announcement-monitoring.service.ts:97-104) — the two recipient strategies are inconsistent. Cannot determine from code whether the global fan-out is intentional (e.g., assuming single-tenant deployments). - Inconsistent recipient selection: permission-based vs deprecated role constants. Incident-report notifications resolve recipients via
PermissionService.getUserIdsWithPermission(businessId, INCIDENT_REPORTS_VIEW_ALL)(notification.listener.ts:313-317), while emergency alerts use the deprecatedMANAGEMENT_ROLESrole query (emergency-alert-notification.listener.ts:40-46;MANAGEMENT_ROLESis marked@deprecatedinpackages/shared/src/enums/user-role.ts:47-53and includes platform roles SuperAdmin/System, which the business filter silently excludes). Requests, medication-stock, and threshold-breach listeners also useMANAGEMENT_ROLES; wound-care and shift-response useBUSINESS_MANAGEMENT_ROLES. Users with Custom roles holding the relevant permission receive incident notifications but not emergency alerts, request, or stock alerts. criticalchannel preset enables email without atemplateId, so those emails never send.CHANNELS.criticalsetsemail: { enabled: true }with no templateId (notification-registry.ts:23-34);EmailChannel.sendreturns early whentemplateIdis missing (email.channel.ts:36-41).EmergencyCallInitiatedandWoundConditionCritical(registry lines 848-889) therefore deliver push + SMS but silently skip email despite the registry claiming email is enabled.- Unscoped admin-ish notification endpoint.
POST /notification/push/send-to-usersis guarded only byJwtAuthGuard+ throttling — any authenticated user can push arbitrary title/body to arbitraryuserIds, including users of other businesses (notification.controller.ts:116-163). Same forPOST /notification/push/sendvariants and queue-inspection endpoints (queue/status,worker/health) which expose operational data to any user. - Chat REST endpoints skip membership and tenancy checks.
GET /conversations/groups/:groupId,GET /:conversationId/media,GET /:conversationId/call-logs,GET /messages/:messageId,PUT /:conversationId/mark-read|mark-delivered, andPUT/DELETE group membersvalidate onlyCHAT_ACCESS, not that the caller is a member of (or in the same business as) the conversation (chat.controller.ts:80-108, 201-256, 326-336, 383-397). The WSSEND_MESSAGEhandler likewise does not verify membership before persisting/broadcasting (chat.gateway.ts:197-231) — only room joins do (chat.gateway.ts:514-529). - Private conversations are not tenant-checked.
createPrivateConversationaccepts any target userId and dedups across all businesses (conversation.service.ts:185-209); nothing prevents a private conversation between users of different businesses. The conversation'sbusinessis silently set to the creator's. - Shared connected-clients registry across both gateways can corrupt presence state.
ws:connected_clientsmaps userId→socketId and is written by both the chat and notification gateways (chat.gateway.ts:124-130,notification.gateway.ts:46-52) — last write wins, andremoveConnectedClientdeletes the user entirely when either socket disconnects (websocket-auth.service.ts:150-184). Consequences: (a)ChatNotificationListener's "offline" check can misclassify users whose notification socket disconnected last; (b)getSocketIdForUserused for cross-namespace emits (chat.gateway.ts:817-824, 872-880) may return a socketId from the other namespace, where the emit silently goes nowhere. The whole hash is also read-modify-written without atomicity (race under concurrent connects). - Resend webhook is unauthenticated when
RESEND_WEBHOOK_SECRETis unset, and email-status correlation matches on hardcoded subject prefixes (app.controller.ts:37-70,app.service.ts:31-36) — the staging[STAGING]subject prefix (email-sender.service.ts:121) breaksstartsWithmatching, so staging email tracking silently no-ops. Cannot determine from code whether this is accepted behavior. readendpoint lacks an ownership check.NotificationService.readlooks up by_idonly (notification.service.ts:219-224) — any authenticated user can mark any user's notification read (and decrement their own counter, desyncing both).softDeletecorrectly filters byuserToNotify(line 291-295).- In-memory call ring timeouts are not multi-instance safe.
ringTimeoutsis a per-processMap(call.service.ts:21); with multiple backend replicas, the accept/decline may land on a different instance than the one holding the timer (mitigated only by the startup sweep and stale-call auto-clear). - Notification listeners swallow all errors (every handler catches and logs without rethrowing — e.g.,
notification.listener.ts:59-64), so recipient-resolution failures are invisible to the emitting workflow; only post-queue failures get BullMQ retries. - Registry templateIds are decorative.
EmailTemplateResolver.resolveignores thetemplateIdargument and always renders the same generic layout (email-template.resolver.tsx:49-62), sowithEmail('care-proposal-status')vswithEmail('shift-assigned')differ only in interpolated text. - Event payload key mismatch: the chat gateway emits
chat.message-sentwith keybusinessId(chat.gateway.ts:256) whileChatMessageSentEventdeclaresbusiness(chat-notification.listener.ts:14); the field is unused so it's benign today, but the type is wrong. - Per-type notification preferences only exist for community posts (
notification-preferences.service.ts:89-107); the preferences UI category model implied by the registry's 20 categories has no backend storage — users can only toggle whole channels. getUserConversationsWithUnreadis sent on every room join withhasMore: false(chat.gateway.ts:553-570), unpaginated — conflicts with the cursor-paginatedGET_CONVERSATIONSpath and may overwrite client pagination state. Intent cannot be determined from code.