Assign & notify

Give training to a group, reach every person in it, and keep chasing until it is done · ← platform hub

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
  1. 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.
  2. assignment-service publishes training.assignments.ten.<tenant>.assignment.createdassignment-service/app/core/nats_client.py:167.
  3. The expansion worker, running in-process, consumes training.assignments.> and resolves the target into people by asking users-auth for group membership. It writes one assignment_recipients row per person. Retargeting an existing assignment goes down the same path.
  4. It then publishes a per-recipient notification event and an expansion_completed (or expansion_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

IssueDetail
A worker that must never be startednotification_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 unreachableThe 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 serviceassignment-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 knowingDaily 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.