In one sentence
An admin says "everyone in Warehouse must finish this module by Friday". The platform turns that one sentence into a row per person, a push notification each, and a reminder schedule that runs until they finish or the date passes.
Two services share the work: assignment-service decides who and when, notification-worker does the reaching. They communicate only by events, so a notification outage delays messages without losing assignments.
Assign and expand
sequenceDiagram autonumber participant AD as Admin participant AS as assignment-service participant UA as users-auth participant EXP as expansion worker participant NW as notification-worker AD->>AS: POST /v1/admin/assignments AS-->>EXP: training.assignments.ten.T.assignment.created EXP->>UA: resolve group members EXP->>EXP: insert one recipient row per person EXP-->>NW: training.notifications.ten.T.assignment.created EXP-->>NW: expansion_completed
- The admin creates the assignment against a target — a whole company, one or more groups, or a list of individuals — with a due date, which is required at the API.
- assignment-service publishes
training.assignments.ten.<tenant>.assignment.created— assignment-service/app/core/nats_client.py:167. - The expansion worker, running in-process, consumes
training.assignments.>and resolves the target into people by asking users-auth for group membership. It writes oneassignment_recipientsrow per person. Retargeting an existing assignment goes down the same path. - It then publishes a per-recipient notification event and an
expansion_completed(orexpansion_failed) summary for the admin inbox — nats_client.py:240, :257.
Expansion used to run twice — an in-process worker and a standalone Deployment consuming the same subject with different dedup rules. That duplication was merged (ARCHITECTURE.md §6 M1); one implementation remains.
The first push
notification-worker consumes training.notifications.ten.*.assignment.> (notification-worker/app/services/notification_service.py:1838). Before sending it writes the notification to its own table, so it appears in the in-app inbox even if the device is offline or the push fails, then hands it to Firebase and stamps delivered_at. Preferences, quiet hours and the per-user daily cap are applied here, not by the sender.
Reminders and escalation
flowchart LR SCH["reminder scheduler
(assignment-service)"] R1["training.assignments.reminders.ten.*"] ESC["training.assignments.escalations.ten.*"] NW["notification-worker"] P["push + inbox"] SCH --> R1 --> NW SCH --> ESC --> NW NW --> P
A scheduler inside assignment-service wakes periodically, finds recipients who are due or overdue, and publishes reminder events (app/services/reminder_scheduler.py:524). An admin can also fire one by hand. When an assignment goes critical the same scheduler publishes an escalation (reminder_scheduler.py:541), which notification-worker delivers as a final nudge to the employee — bypassing quiet hours and the daily cap, but still honouring a global push opt-out.
Reminder candidate selection is deliberately fair: never-reminded first, then longest-waiting, then most urgent, so a large tenant cannot starve a small one.
What the admin sees
Two things flow back. Expansion results reach the admin inbox through expansion_completed / expansion_failed, consumed on their own durables (notification-worker/app/services/admin_notification_service.py:603, :609). And a daily digest worker in assignment-service summarises outstanding work. Admin alerts that need an email mirror are published as auth.admin.alert and picked up by the OTP worker, which is the platform's only email sender.
Where it breaks today
| Issue | Detail |
|---|---|
| A worker that must never be started | notification_event_worker is deliberately not started. If someone "fixed" it, it would consume training.assignments.> under its own durable and re-send the assigned push that the expansion worker already sent. See the assignment verdicts. |
| Half the assignment API is unreachable | The router is mounted at both /v1/admin and /v1; only one half of each pair has a caller or an ingress rule. Note the trap: deleting the /v1 mount would also delete the one live employee route. |
| Notification routes that belong to another service | assignment-service exposes /v1/admin/notifications/*, but the ingress sends that prefix to notification-worker — so those four routes can never be reached. |
| Fixed, but worth knowing | Daily and overdue reminders were silently undeliverable for a period because the consumer filter had one wildcard too many. Fixed by a filter builder plus a durable rename; every live filter's token count is now verified on the notification-worker page. |