Communication — Codebase flow
Maps the send pipeline to workers & files in Tux.
Workers
| Worker | Role |
|---|---|
Tux.Workers.Notification.Scheduler | Scans NotificationScheduler (and MessagingJob) Ready; evaluates Notification Settings; pushes work to Generator. |
Tux.Workers.Notification.Generator | Renders template, resolves recipients; creates UserMessage and/or OutgoingMessage. |
Tux.Workers.Notification.Distributor | "Claims" then sends for real through provider (SendGrid/Twilio). |
| Webhook & Tracker | Receives provider callbacks, updates OutgoingMessage state. |
Worker Automation Scheduler | Scans AutomationScheduler (reminder/report), pushes delivery work to NotificationScheduler. |
Generator: route by notification type
Generator uses the type to select a render handler. Example id-only handlers (read EventData as entity id):
// In Generator
var id = JsonConvert.DeserializeObject<int>((string)job.EventData);
// route: IncidentSubmitted -> HandleNotificationAsync -> HandleIncidentSubmittedAsync
// -> NotificationService.SendIncidentSubmittedNotification(...)Sample handlers: HandleAccountNewRegistration_EmployeeAsync, HandleStaffProfileUpdateAsync, HandleIncidentSubmittedAsync. Each handler resolves recipients (from NotificationSetting subscriber + data in the job), renders template, and creates OutgoingMessage.
Distributor: claim before send
// Pseudo: conditional update
// UPDATE OutgoingMessage SET Status = Sending
// WHERE PartitionKey=@pk AND RowKey=@rk AND Status IN (Created, RetryReady)
// If 0 rows changed → message was already sent/is sending → exit, do NOT call providerAfter successful send → SentToProvider + store provider message id. Webhook advances to Delivered/Opened.
Campaign
MessagingJob (SQL) coordinates campaign/report payload; MessageJobBlobName points to messaging-job container (name/path only — API authorizes before exposing content). Worker Notification Scheduler queries both NotificationScheduler and MessagingJob. Retry reuses compatible blob payload; incompatible → stop sending.
UserMessage (in-app)
Writing data
CustomerInboxPersistService resolves the recipient before writing: only exactly one eligible owner is persisted; staff recipients are also subject to the rollout flag/allowlist. CampaignInAppUserMessagePersistence writes directly to the canonical owner partition; legacy Incident delivery goes through LegacyIncidentUserMessagePersistence and performs a dual-read of legacy keys when moving to Account_{AccountId}.
- Prepare the HTML/text body in Blob container
message-content; the row stores onlyMessageBodyBlobName. The body may reuse an existing blob or a content-addressed namesha256-{hash}, depending on the producer. - Create
UserMessageEntitymetadata: recipient/source scope,Title,MessagePreview, action, display labels, entity coordinates,StatusId, timestamps andCompatibilityHash. EnsureUserMessageAsyncusesAddinsert-if-absent. Retry reads the existing row and preserves recipient-owned state; immutable mismatch returnsVERSION_CONFLICT. Only a newly created row publishes a realtime event.
Storage keys
Canonical PartitionKey:
Account_{AccountId}
Employee_{EmployeeId}
EnterpriseUser_{EnterpriseUserId}
Legacy compatibility PartitionKey:
Customer_{UserId}
Common deterministic RowKey examples:
inbox_{sourcePartitionKey}_{deterministicHash}
{reverseTicks}-{deterministicRowKey}
legacy Incident: {reverseTicks}Customer_{UserId} is used for compatibility reads/deduplication of old data; new delivery does not create this partition. RowKey is producer-specific identity, not a standalone authorization key. reverseTicks must come from a stable source timestamp, not write-time.
Reading and querying
UserMessageInboxController calls UserMessageService; the service resolves authenticated portal → owner scopes before touching Table/Blob:
- List:
GET /api/UserMessageInbox?take&cursor&messageCategoryId&globalView; query eachPartitionKey, include onlyUnread/Read, and filter category/Account/BU/Site server-side. - Paging: default 20, maximum 100; each Azure continuation token is carried in opaque
NextCursor. Multi-scope merge takes the newest head and advances only the selected scope. - Unread:
GET /api/UserMessageInbox/UnreadCount; reads the_InboxUnreadcounter in the same Table, initializing it with a bounded scan when absent. The active contract returns one overall unread total. - Detail/body: point-read by
PartitionKey+RowKey; body is read from Blob after authorization, with no permanent Blob URL and preview-only list responses. - State:
PUT .../{action}with the current ETag;MarkRead,Archive,Save,Unsaveare conditional updates. Save setsSavedOn, Unsave clearsSavedOn, without copying the row or resetting other status.
The retention job finds unsaved rows older than three months through the maintenance index _InboxUserMessageMaintenance_{yyyyMM} and deletes them conditionally by ETag; saved rows are exempt.
Realtime Inbox
| Component | Location | Role |
|---|---|---|
| Publisher | Tux.Service.AzureService.RedisUserMessageRealtimePublisher | Writes one inbox.changed event to Redis Stream after a new UserMessage is created. |
| Stream consumer | Tux.Api.Infrastructure.Inbox.UserMessageRealtimeStreamConsumer | Each API instance reads and ACKs events, then fans out to local connections. |
| Connection manager | Tux.Api.Infrastructure.Inbox.UserMessageRealtimeConnectionManager | Registers by JWT subject, replays Last-Event-ID, bounds the buffer and requests sync when needed. |
| SSE endpoint | Tux.Api.Controllers.UserMessageInboxController.StreamAsync | Returns text/event-stream, heartbeat and event metadata; it does not return Inbox body content. |
| Tuke client | Tuke/app/lib/user-message-realtime.ts + Tuke/app/stores/userMessage.ts | Parses events, reconnects/backoffs, deduplicates and refreshes REST unread/first page. |
| Topi bridge | postMessage from Tuke | Updates the host badge; Topi does not open a second SSE connection for the iframe. |
The flow is:
- The Producer persists
UserMessagethrough the existing persistence boundary. - Only a creation outcome emits an event; re-ensure or state update emits no creation event.
- Tux API consumes the Redis Stream and sends the event to the JWT
sub's matching connections. - Tuke treats the event as invalidation, then calls REST for data under the current scope.
- Reconnect uses replay; if the event has expired from retention, the client receives
inbox.sync-requiredand revalidates through REST.
Real-world example: A Tux worker creates a
UserMessagefor an employee. → The Publisher writes the Redis event; Tuke receives it even when its connection is held by another API instance. → Tuke refreshes unread state while REST continues to own read authorization.
What Notification Settings control
Scheduler reads BU config:
NotificationOption— catalog of notification types (new types must be seeded here).NotificationSetting— enable/disable + subscriber by BU.
Types not seeded or with no subscriber → handler skips gracefully (no send).
DeliveryDispatchResult
Each dispatch result is stored in AutomationScheduler.DispatchResultJson: delivery path + coordinates (notification/scheduler/messaging-job/user-message/outgoing-message) + SkippedReasonCode/ConflictReasonCode/Summary. Compact, merged on retry, never deleted.
Important tests
- Direct reminder retry → one
NotificationJob; Generator retry → oneOutgoingMessageper recipient/method; duplicate Distributor message → does not call provider a second time. - UI-only reminder →
UserMessage, noOutgoingMessage; UserMessage retry with stable source ticks → one row. - Missing scheduler recreated through dispatch guard; hash mismatch → stop send.
- Storage adapters preserve keys supplied by caller.
- Report/campaign retry reuses compatible
MessagingJob + Blob.
Next: Common cases.
Traceability and report runs
- The Generator resolves the source before building
OutgoingMessageand uses a source-key/partition helper; invoices use invoice GUIDs and account-scoped booking/statement flows use account GUIDs, with fallback when unresolved. - Distributor claims carry
ClaimIdand claim time; stale claims are recovered by delayed checks, while provider progress is not overwritten by a retry. - Report Subscription uses
ReportSubscriptionScheduleras run history and stores the file before delivery; oversized runs become link-only rather than failing on attachment size. - Notification Setting list reads the BU aggregate and batch-writes Site/BU contact toggles; the detail page no longer owns the copy toggle.
Real-world example: The Generator retries after a report file already exists. → It reuses the run/claim coordinate and does not create duplicate file delivery; history keeps the prior run state.