Skip to main content

Notifications, escalation and tenant switch

Notifications​

Model​

notifications (one row per recipient): tenant_id (nullable - a security notice about the user themself belongs to no tenant), user_id (the user's id as a string, no FK, like Membership), type (dotted key such as ticket.escalated, ticket.assigned, security.new_device), title, body, data (JSONB context), link (dashboard-relative path), read_at, created_at (clock_timestamp() so ordering is stable inside a transaction) and digested_at. Index (user_id, read_at, created_at) serves the feed and unread count.

Sending: notify​

from app.services import notifications

await notifications.notify(
db,
[user_id],
"security.new_device",
"New sign-in",
"Chrome on macOS",
link="/settings/security",
tenant_id=None,
channels=None,
)

Any service can call it (e.g. new-device and MFA alerts). It commits, never raises, and returns the stored rows. Channels come from user_preferences.notification_channels (subset of in_app, email, sse):

ChannelEffectIf preferences are unset
in_approw stored, visible in the feedon
ssepushed to the user's open streamon
emailimmediate mail via send_notification_email (best-effort) and eligibility for the digestoff (opt-in)

The optional channels argument narrows delivery further (intersection with the user's own channels); the escalation matrix uses it to honour each entry's notify_channels.

Endpoints (/api/v1/notifications, any authenticated user, own rows only, no X-Tenant-ID)​

Method and pathNotes
GET /notifications?page&limit&unreadnewest first; {results, meta:{page,limit,total,unread_count}}
GET /notifications/unread-count{unread_count}
POST /notifications/{id}/readthe updated notification; 404 for someone else's
POST /notifications/read-all{updated}
DELETE /notifications/{id}204; 404 for someone else's
GET /notifications/streamServer-Sent Events, below

SSE stream​

GET /notifications/stream (text/event-stream) authenticates with the session cookie, so a browser EventSource works unchanged. Frames:

retry: 3000

event: ready
data: {"unread_count":3}

id: <notification uuid>
event: notification
data: {"id":"...","type":"ticket.escalated","title":"...","body":"...","data":{...},"link":"/tickets/...","read_at":null,"created_at":"..."}

: ping

No Redis. Within a process an in-memory hub (NotificationHub) fans a freshly stored notification out to that user's open streams. Because several API workers may run, each stream also polls the table every few seconds for rows newer than the last one seen (with a small overlap and an id de-duplication set), so a notification created by another worker still arrives. Last-Event-ID (sent automatically by EventSource on reconnect) replays everything newer than that notification. The request's DB connection is released before streaming starts. A user with sse enabled but in_app disabled receives transient events with id: null that are not stored.

Settings: NOTIFICATIONS_SSE_POLL_SECONDS (3), NOTIFICATIONS_SSE_HEARTBEAT_SECONDS (15), NOTIFICATIONS_SSE_MAX_SECONDS (0 = unlimited; set to bound a stream, used by tests).

Email digest​

notifications.send_digests mails each user whose channels include email one message listing their unread, not-yet-digested notifications from the last 24 hours, then stamps digested_at. Run it from the scheduler (NOTIFICATIONS_DIGEST_ENABLED=true, daily at NOTIFICATIONS_DIGEST_HOUR_UTC, default 7) or with uv run python scripts/send_notification_digests.py. Off by default.

Escalation​

Matrix​

escalation_matrix (per tenant): level (L1..L5), user_id (staff user, nullable), contact_email (external mailbox, nullable; one of the two is required), notify_channels (in_app/email, default both), escalate_after_minutes (default 60), is_active, notes. Unique on (tenant_id, level, user_id).

Endpoint (/api/v1/escalation, needs X-Tenant-ID)Access
GET /matrix?level&activeescalation:read
POST /matrix, PATCH /matrix/{id}, DELETE /matrix/{id}escalation:write (409 on duplicate user at a level, 404 for an unknown user)
GET /levelsescalation:read - the five tiers with staff_count (users whose escalation_level matches) and matrix_entries

Escalating a ticket​

tickets gains escalation_level, escalated_at, escalated_by (also returned by TicketRead). A ticket with no level is implicitly at L1. POST /tickets/{id}/escalate {level?, reason} (tickets:escalate, normal ticket visibility rules) moves it to the next level - the first escalation lands on L2 - or to an explicit level, which must be strictly higher (400 otherwise, also for resolved/closed tickets and when already at L5). It writes an audit row (domain ticket, action escalate, payload from/to/reason/automatic - the escalation history, kept out of the client-visible comment thread), then notifies every active matrix entry for the new level: users through notify (type ticket.escalated, link to the ticket) and bare contact emails by email.

Adding a user to a ticket (POST /tickets/{id}/members) notifies them (ticket.assigned) unless they were already a member or added themself.

SLA auto-escalation​

escalation.escalate_overdue_tickets(db) escalates every open/in-progress ticket that has sat at its level longer than the smallest escalate_after_minutes among that tenant's active entries for the level, measured from escalated_at (or creation). Levels without entries never time out. Actor is system. It runs from the scheduler every ESCALATION_AUTO_INTERVAL_MINUTES (5) only when ESCALATION_AUTO_ENABLED=true (default off).

Tenant switch​

POST /api/v1/tenants/switch {tenant_id}: a platform role may switch to any tenant, other users need a tenant-scope membership (403 otherwise, 404 for an unknown tenant). It writes an audit row (domain tenant, action switch, in the target tenant) and returns the tenant (TenantRead). Tokens are not re-issued - the tenant is still chosen per request through X-Tenant-ID; the dashboard calls this when the user picks a tenant so the switch is explicit and audited.