Notification Delivery
service-desk/notification-delivery
| Kind | backend |
|---|---|
| Domain | service-desk |
| Maturity | staging |
| Reusable | extract |
| Stack | fastapi-0.118, sqlalchemy-2.0-sync, postgres, apscheduler-3.11, requests-2.32, pydantic-2.12 |
| Depends on | _shared/fastapi-project-layout, _shared/alembic-migrations, _shared/scheduler-and-background-work, _shared/workspace-scoping, _shared/test-layout |
| Frontends | service-desk/ui/ticket-detail |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
When one ticket event happens (a new ticket lands, nobody accepts it in time, a deadline is close or missed, a client replies or signs off), DeskNotification.emit fans that event out by per-employee preference to the right agents over Telegram first with an automatic e-mail fallback, deferring anyone in quiet hours except a Klasse 1 (k1) emergency, while notifications.py mails the requester their own five German status updates and reminders.run_desk_reminders sweeps unaccepted/due-soon/overdue/review-timeout on a 5-minute scheduler tick. The invariant that makes it trustworthy: emit() never raises, any exception rolls the session back and returns [], and the reminder sweep never sends the same (ticket, user, event) twice inside its dedupe window, so a crash in one delivery or one bad ticket never blocks the rest of the fan-out or repeats a ping (desk_notification.py 1-14, 265-335; reminders.py 55-74, desk_notification.py 394-404).
When to use
Read it before touching DeskNotification.emit/release_deferred, resolve_recipients/_select_by_prefs, quiet_hours_release, _deliver, recently_delivered, or run_desk_reminders/_remind_ticket; before changing what counts as an employee's notification preference versus a workspace default; and when someone asks "why didn't X get pinged", "why did the Telegram message not have a link in it", "why did the reminder fire twice", "does Telegram or e-mail go first", or "why is this ping stuck in quiet hours". Trigger phrases: "DeskNotification.emit", "quiet_hours_release", "unaccepted_after_minutes", "desk_notification_deliveries", "employee notification preferences", "Telegram pairing" (the fan-out side of it), "requester status mail", "review_timeout".
Not this recipe: the ticket lifecycle mutation itself (ticket_service.py's create/accept/transition/message logic, a neighbouring recipe, this recipe only consumes the Effects.mail/Effects.emit queue it produces, ticket_service.py 44-84); the routing engine that decides who gets pre-assigned or named in notify (routing.py::route_ticket/notify_recipients, a neighbouring recipe, this recipe's resolve_recipients only merges routing's explicit recipient list with preference-selected agents, desk_notification.py 189-216); the AI intake check and the workspace configuration document itself (config_service.resolved_desk is only read here for class labels and employee_defaults); the Telegram bot's INBOUND command/reply surface, handle_ticket_reply, handle_callback_accept/handle_callback_ask, handle_tickets_overview, the assign/extend/status-word chat commands (telegram_bot.py 375-793), a different recipe that turns a chat reply into a ticket mutation; this recipe only calls send_message, ticket_buttons, is_configured, deep_link (telegram_bot.py 101-212) to push a ping out, never to read one back; the pairing code exchange's /start <code> handler in the bot itself (cited for context, not this recipe's subject, employee_prefs.start_pairing/complete_pairing are, the bot's handle_command is not).
Stack assumptions
At 62ba4fc: fastapi==0.118.2, synchronous handlers on sqlalchemy.orm.Session (SQLAlchemy==2.0.43), psycopg2-binary==2.9.10 against Postgres, APScheduler==3.11.0 for the reminder job, requests==2.32.5 for the Telegram HTTP calls (telegram_bot.py 18-20), pydantic==2.12.0 for EventPrefsUpdate/QuietHoursUpdate/EmployeePrefsUpdate (schemas/service_desk_schemas.py 389-417). Foundations assumed: S4 (fastapi-project-layout) for the app and the VerifiedUser/DatabaseSession dependencies; S8 (alembic-migrations) for the two chained migrations under Deploy & ops; S12 (scheduler-and-background-work) for the APScheduler singleton the desk_reminders job registers on (scheduled_jobs.py 451-459) and for the same leader-only-daemon-thread pattern the Telegram poller copies from the accounting receipt bot; S6 (workspace-scoping) because an employee's fan-out targets and their own preferences view are computed across every workspace they serve (agency-wide, served_workspace_ids), never from a single X-Workspace-ID header the way a tenant-scoped route works.
Environment, all in core/config.py: DESK_TELEGRAM_BOT_TOKEN (~1047, Optional[SecretStr], default None, unset makes telegram_bot.is_configured() false everywhere and the whole fan-out becomes email-only); DESK_TELEGRAM_BOT_USERNAME (used only by deep_link, unset makes pairing return no usable deep link but does not error); DESK_REMINDERS_ENABLED (default True, reminders.py 27-28 short-circuits the whole job to {"skipped": 1} when false); FRONTEND_URL (43, default http://localhost:3000, used to build portal_link/admin_link, notifications.py 42-47).
Fail behaviour: DeskNotification.emit is wrapped end to end in try/except Exception that logs a warning, rolls back the session, and returns [] (desk_notification.py 269-335, matching the "never raises" promise the module docstring makes); run_desk_reminders wraps the whole job in its own try/except (rollback on failure) and wraps each ticket's _remind_ticket call individually so one broken ticket is logged and skipped without silencing the rest (reminders.py 58-70); send_requester_mail is similarly never-raising (notifications.py 58-83).
Data model
desk_notification_deliveries, the fan-out log AND the reminder job's own dedupe source (models/service_desk_model.py 371-416; created by 20260904_0001_service_desk_core.py, provider_message_id added by 20260905_0002_desk_telegram_reply.py):
desk_notification_deliveries (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
ticket_id uuid REFERENCES desk_tickets(id) ON DELETE CASCADE, -- NULL only for the ticket-less "test" event
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- the employee who was (or would be) pinged
event varchar(40) NOT NULL, -- new_ticket | assigned | unaccepted | due_soon | overdue |
-- client_replied | client_review_done | review_timeout |
-- domain_link_budget | domain_dns_warning | test
channel varchar(10) NOT NULL, -- telegram | email
status varchar(10) NOT NULL, -- sent | failed | deferred
send_after timestamptz, -- quiet-hours release time; set only when status='deferred'
sent_at timestamptz,
error text,
provider_message_id varchar(64), -- Telegram's own message id; NULL on email rows and on any send that failed before Telegram answered
created_at timestamptz NOT NULL DEFAULT now()
);
INDEX ix_desk_deliveries_ticket_user_event (ticket_id, user_id, event, created_at) -- recently_delivered's dedupe lookup
INDEX ix_desk_deliveries_deferred (send_after) WHERE status = 'deferred' -- release_deferred's sweep
INDEX ix_desk_deliveries_provider_msg (user_id, provider_message_id)
WHERE provider_message_id IS NOT NULL -- resolves a Telegram reply back to its row; out of this recipe's scope (When to use)
user_preferences (models/memory_model.py, foreign to this recipe), one row per (user_id, category='desk_notifications'), preference JSONB shaped by DEFAULT_PREFS (employee_prefs.py 29-43): telegram {chat_id, username, linked_at} | null; pairing {code, expires_at} | null (never leaves this module except to the bot, module docstring 7-8); events {new_ticket: {k1,k2,k3: bool}, unaccepted_after_minutes: int 0-1440, due_soon/overdue/client_replied/client_review_done/review_timeout: bool}; quiet_hours {enabled: bool, start: "HH:MM", end: "HH:MM", weekend: bool}; email_fallback: bool.
What is NOT stored anywhere in this recipe: the Telegram chat id or the pairing code on the surface the web can read, public_view strips both down to {linked: bool, username, linked_at} (employee_prefs.py 142-155); a provider response body on a failed send, error and every log line carry a fixed string ("sendMessage failed", "transport returned False") or a caught exception's str(), never Telegram's or the mail transport's raw response (desk_notification.py 234-251, 378-388).
API surface
| Route | Auth | Tenancy | Body | Codes | Notes |
|---|---|---|---|---|---|
GET /api/desk/me/notifications (service_desk_router.py 939-944) | DeskEmployee (VerifiedUser + served_workspace_ids, 93-97, 111; 403 {"error":"employee_required"} with zero served workspaces) | agency-wide, every workspace the caller serves | – | 200 employee_prefs.public_view(...) | defaults_hint keyed per served workspace, capped at the first 20 (_prefs_view, 930-936, served[:20]) |
PUT /api/desk/me/notifications (947-953) | DeskEmployee | same | EmployeePrefsUpdate (events/quiet_hours/email_fallback, all optional; schemas/service_desk_schemas.py 389-417) | 200 public_view(...); 422 on a type/pattern violation | unknown keys under events/quiet_hours are silently dropped on write (employee_prefs.update_prefs, 122-139), not refused |
POST /api/desk/me/telegram/pairing (956-963) | DeskEmployee | n/a | – | 200 {code, deep_link, expires_at}; 503 {"error":"telegram_not_configured"} when DESK_TELEGRAM_BOT_TOKEN unset | code is 8 chars from a 0/O/1/I-free alphabet, 15-minute TTL (employee_prefs.py 26-27, 163-173) |
DELETE /api/desk/me/telegram (966-971) | DeskEmployee | n/a | – | 204 | idempotent; unlinking an already-unlinked user still 204s |
POST /api/desk/me/telegram/test (974-1004) | DeskEmployee | n/a | – | 200 {sent: bool, channel: "telegram"|"email"} | writes one desk_notification_deliveries row, event="test", ticket_id=NULL, workspace_id=served[0] (999-1002); always the English TEST_MESSAGE regardless of the employee's own language (desk_notification.py 55-57) |
The Python entrypoints the scheduler and ticket_service.py's Effects.run call, not routes, but the actual surface a rebuild reimplements:
| Function | Owns its own transaction | Never raises | Returns |
|---|---|---|---|
DeskNotification.emit(db, event, ticket, recipients=None, context=None, ignore_quiet_hours=None, desk_settings=None) (265-335) | no (caller's transaction; flushes) | yes (269-335) | List[DeskNotificationDelivery], possibly [] |
DeskNotification.release_deferred(db, now=None) (337-391) | no (flushes) | the per-row send attempt is guarded; a db.get that raises is not | int rows released |
recently_delivered(db, ticket_id, user_id, event, within) (394-404) | read-only | yes | bool |
notifications.send_requester_mail(db, workspace_id, to_email, subject, body_html) (58-83) | no | yes (62-83) | bool |
reminders.run_desk_reminders(db=None) (26-74) | commits itself when it opened its own session | yes (65-70) | Dict[str, int] stats |
Application logic
- A ticket mutation (
ticket_service.py, a neighbouring recipe) never sends anything itself, it queues side effects on anEffectscollector:Effects.mail(fn)for a requester-mail closure,Effects.emit(event, ticket_id, recipients, context, ignore_quiet_hours)for an employee ping (ticket_service.py44-59). The router commits the ticket mutation first, then callseffects.run(db), which re-fetches the ticket, callsDeskNotification.emitfor each queued emit, then commits again (61-84), a mail or a ping is only attempted for state that actually landed, and a failure inside any one callable is swallowed per-callable (64-79) so it can never roll back the ticket write that already committed. - Inside
emit(): resolvedesk_settingswhen not passed (config_service.resolved_desk, 273-276); defaultignore_quiet_hourstoTrueonly when the ticket isk1(277-278);resolve_recipients()computes the target set (189-216),new_ticketadds the routed assignee plus everyone whoseevents.new_ticket[class_key]is on (195-198,_select_by_prefs170-186);client_replied/client_review_done/review_timeoutdefault to the assignee, else the config'sdefault_assignee, else preference-selected agents, but only when the caller passed no explicit recipients (199-211);unaccepted/due_soon/overduefall back to preference-selected agents under the same "only if nothing explicit" rule (212-214); the final set is always intersected withagent_user_ids(workspace)so an explicitly-named non-agent is dropped (216,roles.py132-139). - No targets → return
[]immediately (281-282), no database write at all. - The client's first inbound message is pulled into
context['body']when the caller did not already supply one (286-298);render_text()builds one HTML-ish message shared by Telegram and (once the link is appended) e-mail (299-307, 116-167): a bold "TICKET-KEY · Class label" head, the event label plus the ticket title, "from<requester>·<workspace>", the client's own message truncated at 500 characters, up to 6 non-empty structured fields, the first-response due date only while no first response has happened yet, an effort estimate, and an optionalcontext['detail']line. - Per targeted user (311-326): load prefs; if
ignore_quiet_hoursis false andquiet_hours_release(prefs, now)returns a release time, write onestatus='deferred'row (channel guessed from whether a chat id is on file) and move on WITHOUT sending anything (316-325); otherwise call_deliver(), which tries Telegram first when a chat id is set andis_configured(), and sends the e-mail fallback only when Telegram did not succeed ANDprefs['email_fallback']AND the user has an e-mail (219-252), one delivery row per channel actually attempted, never both on a Telegram success. release_deferred(db, now)(338-391), called by the reminder job every 5 minutes: for every deferred row whosesend_after <= now, re-renders the text fresh (the originalcontextis NOT preserved across the deferral, only whatrender_textcan recompute straight from the ticket anddesk_settingssurvives, 358-365, see Gotchas), sends it through the same Telegram-then-email logic, and updates the SAME row in place rather than inserting a new one (368-389); a user or ticket that vanished, or a ticket already closed, fails the row without attempting a send (350-357).reminders.run_desk_reminders(db=None)(26-74): short-circuits to{"skipped": 1}whenDESK_REMINDERS_ENABLEDis false (27-28); loads every open ticket wherefirst_response_at IS NULLorstatus IN (new, client_review)(37-44); each ticket runs inside its owntry/except, logged and skipped on failure (58-61):unacceptedfires once per agent whoseunaccepted_after_minutes > 0, oncereceived_atplus that many minutes has passed ANDrecently_delivered(..., within=None)is false, i.e. at most once ever per ticket per employee (87-94);due_soonfires once, insidedue_soon_minutesoffirst_response_duewhile no first response exists (96-103);overduerepeats everyoverdue_repeat_minutes(60, or 15 for k1) pastfirst_response_due, gated byrecently_delivered(..., within=that repeat interval)(104-109);review_timeoutfires once, afterreview_timeout_working_daysbusiness days pastclient_review_started_at, targeted at the assignee, elsedefault_assignee, else preference-selected agents (111-124); finallyrelease_deferred()runs and the whole batch commits once (63-64).- The five requester mails (
notifications.py) are called directly fromticket_service.py'sEffectsclosures, never fromDeskNotification:mail_ticket_createdon create (ticket_service.py371-378),mail_outbound_messagewhen an agent's reply is not internal (223-233),mail_deadline_extendedonextend(722),mail_client_reviewwhen a ticket moves toclient_review(895, 916),mail_closedon done/cancel (942, 965); each checksdesk_settings.notifications.requester_emailfirst (notifications.py86-87) and is otherwise unconditional, there is no per-requester preference, only that one workspace-wide switch. (mail_accepted, 116-125, exists in this file butticket_service.py's own comment at line 657 says it is queued nowhere, that mail is intentionally dropped; it belongs to the ticket-lifecycle recipe to explain, cited here only because it lives in this recipe's file.)
Invariants
emit()never raises and never leaves a partial transaction visible to its caller: any exception rolls the session back and returns[](desk_notification.py269-335; the defensive posture is exercised at the reminder-loop level bytest_malformed_preferences_are_refused_and_never_abort_the_job, no test isolatesemit's own catch directly).- Telegram is always tried before e-mail, and e-mail fires only on a Telegram failure or absence, never in addition to a Telegram success (
_deliver, 219-252;test_fanout_prefers_telegram_and_falls_back_to_email). - A k1 ticket ignores quiet hours; every other class defers to the employee's release time and is later released by updating that same row, not inserting a new one (277-278, 316-325, 368-389;
test_quiet_hours_defer_k3_not_k1_and_the_job_releases). - A non-agent is never in the delivered set even when named explicitly as a recipient (
resolve_recipients, 216;test_fanout_respects_preferences_and_email_fallback_switch, which assertsDeskNotification.emit(..., recipients=[requester.id]) == []). - The reminder sweep de-dupes per
(ticket_id, user_id, event):unaccepted,due_soonandreview_timeouteach fire at most once per ticket per employee ever (within=None);overduere-fires only after its own repeat interval elapses (recently_delivered, 394-404;reminders.py92, 101, 107, 122;test_reminder_job_unaccepted_due_soon_overdue_and_stop_after_accept,test_reminder_job_k1_repeats_every_15_minutes). - One ticket that raises inside the reminder loop never stops the sweep for any other ticket, and
release_deferredstill runs afterward (reminders.py54-63;test_malformed_preferences_are_refused_and_never_abort_the_job, which monkeypatchesrecently_deliveredto raise for one specific ticket only). - Employee-facing text is always English and client-facing mail is always German, from the same ticket event, never crossed (
desk_notification.pymodule docstring 9-13,EVENT_LABELS;notifications.pymodule docstring 6-7;test_one_event_two_languages). - A stored preference row of any shape (wrong types, unknown keys, written by hand or an older version) is coerced to a safe default on read rather than aborting the reminder job (
_merged,employee_prefs.py80-102;test_malformed_preferences_are_refused_and_never_abort_the_job).
Frontend
The module that renders what this fan-out points to, by id:
service-desk/ui/ticket-detail the admin ticket view every admin_link() in an e-mail and the
Telegram "🔗 Open in desk" button point back to
(`/admin/desk/{workspace_id}/{ticket_id}`, notifications.py 46-47);
also the nearest existing surface for a per-ticket notification
history, since no dedicated admin route reads
desk_notification_deliveries at 62ba4fc (below). The employee
settings screen implied by SPEC.md's `/settings/desk-notifications`
path, where GET/PUT /me/notifications, the pairing button and the
test-message button actually render, has no written recipe of its
own yet in this kit; naming ticket-detail here is the nearest
written surface, not a claim that it renders the preferences screen.
Minimum admin/debug surface (no dedicated route exists at this commit, these are the queries and log lines a human runs by hand): SELECT ticket_id, user_id, event, channel, status, error, created_at FROM desk_notification_deliveries WHERE ticket_id = :tid ORDER BY created_at (what recently_delivered and release_deferred both key off); SELECT * FROM desk_notification_deliveries WHERE status = 'deferred' AND send_after <= now() (the live release_deferred queue, backed by ix_desk_deliveries_deferred); the log line desk reminders: ticket %s skipped (reminders.py 61, the one line that says a ticket's reminders silently stopped firing) and SCHEDULER: desk reminders {stats} once per tick with a non-zero stat (scheduled_jobs.py 571-573).
Security & tenancy
Auth dependency: every route here sits behind DeskEmployee = Depends(_require_employee) (service_desk_router.py 93-97, 111), itself VerifiedUser (a platform login) plus served_workspace_ids(db, user) (roles.py 79-104), a logged-in user with zero served workspaces gets 403 {"error":"employee_required"} before any preference or pairing logic runs; a requester gets 403 on every one of these routes even while authenticated (test_pairing_503_without_token_and_test_message, test_fanout_respects_preferences_and_email_fallback_switch). Tenancy: this surface is deliberately agency-wide, not single-workspace, "served" is every workspace the user serves (an admin serves all, roles.py served_workspace_ids), and defaults_hint in the prefs view is keyed per served workspace (service_desk_router.py 930-936); resolve_recipients and the reminder sweep both scope by one ticket's own workspace_id at a time (agent_user_ids(db, ticket.workspace_id), desk_notification.py 194; reminders.py 79-83), never by a header.
Fail direction: emit() fails closed on delivery correctness (a Telegram failure is never counted as delivered and always tries the e-mail fallback, 231, 239) but fails OPEN on the surrounding operation, a mail or emit failure never blocks or rolls back the ticket mutation that queued it (ticket_service.py Effects.run 64-79); this recipe is a best-effort side channel, never a gate a ticket write depends on. Pairing fails closed: an expired or already-consumed code returns None from complete_pairing (employee_prefs.py 188-221) and the bot answers MSG_BAD_CODE (cited for context; the bot's own command dispatch is out of scope, see When to use). Secrets and PII at rest: DESK_TELEGRAM_BOT_TOKEN is a SecretStr; a user's Telegram chat_id and pairing code live in user_preferences.preference JSONB and are explicitly stripped by public_view before the web ever sees them (142-155); a failed delivery's error column and every log line for it are a fixed literal or a caught exception's str(), never a provider response body (234-251, 378-388). Rate limits: none dedicated to this seam at 62ba4fc, the pairing code's own 15-minute TTL and single-use consumption on /start (188-221) is the only throttle on POST /me/telegram/pairing/test. Attacker-controlled inputs: none beyond the caller's own session, every route reads only the caller's own user_id, and EmployeePrefsUpdate's fields are typed and bounded by Pydantic (unaccepted_after_minutes 0-1440, HH:MM-pattern quiet hours, schemas/service_desk_schemas.py 389-417) with unknown keys ignored on write rather than stored.
Tests
Run 2026-09-12 against a scratch Postgres database, using the venv at alchemy_labs/backend/.venv, on the pinned commit's own working tree (no code changed):
createdb prima_desk_scratch
TEST_DATABASE_URL=postgresql://localhost:5432/prima_desk_scratch \
DATABASE_URL=postgresql://localhost:5432/prima_desk_scratch \
SYNC_DATABASE_URL=postgresql://localhost:5432/prima_desk_scratch \
ENVIRONMENT=development WEBAUTHN_RP_ID_BACKFILL=localhost \
python -m pytest tests/test_desk_notifications.py -q --no-cov -o addopts="" -p no:cacheprovider
prints 13 passed, 1 failed, not a clean pass. Important setup detail: this test file's test_engine fixture drops and recreates every table itself from SQLAlchemy's Base.metadata (tests/conftest.py 54-105), NOT alembic upgrade head, running the migrations first on the same database before this fixture runs collides on constraint names (UndefinedObject: constraint "fk_leads_workspace_id_workspaces" does not exist, measured); use a database alembic has never touched.
The one failure, test_fanout_prefers_telegram_and_falls_back_to_email, is a genuine mismatch between the test and this commit's code, not an environment artifact: the test asserts admin_link(...) and the literal "Accept:" appear inside msg["text"] (test_desk_notifications.py 236), but ticket_buttons() puts the "✅ Accept" button and the "🔗 Open in desk" link only in the Telegram reply_markup inline keyboard, never in the message text (telegram_bot.py 194-212); render_text() only appends the admin link to email_text, never to the Telegram text (desk_notification.py 299-307). Flagged here, not fixed, per this recipe's read-only mandate on the source repo, see Gotchas.
grep -c "^def test_" tests/test_desk_notifications.py prints 14; grep -c "@pytest.mark.parametrize" prints 0; grep -c "@pytest.mark.critical" prints 5 (the pairing round-trip, the fan-out/fallback, the quiet-hours defer-and-release, the reminder job's unaccepted/due-soon/overdue sequence, and the one-event-two-languages split).
Hermetic vs live: hermetic. requests.post is monkeypatched to a recording list for every Telegram call (tests/desk_support.py::telegram_log, ~214-249) and the mail transport is similarly captured (mail_log, ~179-213); clock.now is frozen/advanced by frozen_clock (~254+) so quiet-hours and reminder-interval math is deterministic; nothing in this file reaches a real Telegram API or a real mailbox. Known-answer vector: test_quiet_hours_release_math (no database) checks five exact datetimes against fixed (start, end, weekend) configs, including the Saturday-to-Monday weekend-quiet case and a same-day noon-to-1pm window.
What a new app adds and this branch lacks: no dedicated admin/debug route reads desk_notification_deliveries (Frontend, above); no rate limit on the pairing/test endpoints (Security & tenancy); no test covering what a deferred-then-released ping actually contains versus its immediate counterpart, given the lost context on release (Application logic step 6, Gotchas).
Deploy & ops
Config keys per app: DESK_TELEGRAM_BOT_TOKEN, DESK_TELEGRAM_BOT_USERNAME, DESK_REMINDERS_ENABLED, FRONTEND_URL (all above). Migrations, in chain order (backend/migrations/versions/): 20260904_0001_service_desk_core.py (service_desk_core_001, down-revision commerce_second_image_001) creates desk_notification_deliveries with its two original indexes (210-234); 20260905_0002_desk_telegram_reply.py (desk_telegram_reply_001, down-revision desk_domain_login_001, i.e. after the unrelated slice-2 login migration) adds provider_message_id and its partial index. Both apply cleanly to a fresh alembic upgrade head (verified 2026-09-12 against a scratch database: the chain runs 19 revisions with no error to head prima_device_codes_001).
Scheduler: run_desk_reminders registered as its own job, id='desk_reminders', IntervalTrigger(minutes=5), coalesce=True, max_instances=1 (scheduled_jobs.py 451-459), on the same session-level Postgres advisory-lock leader election every other scheduled job in this codebase uses, no desk-specific lock. The Telegram poller (the inbound command/reply surface, out of this recipe's scope) starts as a leader-only daemon thread right after scheduler.start(), copying the accounting receipt bot's runtime shape (docs/service-desk/SPEC.md line 47).
Argus-style checks: POST /api/desk/me/telegram/test with no DESK_TELEGRAM_BOT_TOKEN set returns 200 {"sent": true, "channel": "email"} (proves the fallback path end to end without a real bot); SELECT count(*) FROM desk_notification_deliveries WHERE status='deferred' AND send_after < now() - interval '1 hour' is the number of pings release_deferred should have picked up on an earlier tick and did not, expected 0 in steady state. Rollback: both migrations downgrade cleanly (drop_column/drop_index, drop_table); a desk_notification_deliveries row is never referenced by anything outside this recipe and recently_delivered, so rolling back the code needs no data unwind.
Gotchas
- 2026-09-12 (measured against 62ba4fc,
backend/tests/test_desk_notifications.py): running the pinned suite produces13 passed, 1 failed, not a clean pass, see Tests for the exact mismatch (ticket_buttonsputs the admin link and Accept button inreply_markup, the test asserts they are inmsg["text"]). Treat this recipe's "known good" state as 13/14 rather than assuming the suite was green when this recipe was written. - 2026-09-12 (measured:
docs/service-desk/SPEC.mdlines 3-4, written 2026-09-04, "Branch feat/service-desk, staging only... Nothing here touches prod";git logshowsbb94fd5"Reunify: merge feat/service-desk into main" landed 2026-09-12 16:19, about two hours before this recipe's pinned commit 62ba4fc at 18:23, same day): the code is onmainand the router is mounted unconditionally (app_fastapi.py829, no feature flag), but the authoritative spec doc was never amended past "staging only" and no changelog or ops note in this repo claims a production cutover for this fan-out specifically.maturity: stagingreflects that gap, notprod, verify the actual deploy state before upgrading it, per this kit's own rule against assumingprod. - 2026-09-12 (measured,
desk_notification.py338-391 vs 265-335): a deferred (quiet-hours) delivery'scontext, the client's first-message snippet, any per-emitcontext['detail'], is not persisted on the row and is lost by the timerelease_deferredre-renders it; only whatrender_textcan recompute straight from the ticket anddesk_settingssurvives the deferral. A rebuild that wants a released ping to look identical to an immediate one needs to persistcontexttoo. - 2026-09-12 (measured,
employee_prefs.py176-185, 208-221, 238-254): every pairing/unlink lookup by code or by chat id is a full table scan over everydesk_notificationspreference row (db.query(UserPreferences).filter(category=...).all()then a Python loop), justified in the module docstring as "a handful of employees, no index needed", genuinely fine at agency scale, a latent cost only if this preference category is ever reused for a much larger population.
Reference implementation
Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.
alchemy_labs/backend/services/service_desk/desk_notification.py(404 lines, whole file in scope): module docstring (1-14);EVENT_LABELS/TEST_MESSAGE(35-57);quiet_hours_release(68-96);render_text(116-167);resolve_recipients/_select_by_prefs(170-216);_deliver(219-255);DeskNotification.emit(265-335);DeskNotification.release_deferred(337-391);recently_delivered(394-404).alchemy_labs/backend/services/service_desk/notifications.py(197 lines, whole file in scope):send_requester_mail(58-83); the five requester mails,mail_ticket_created(95-113),mail_accepted(116-125, queued nowhere perticket_service.pyline 657's own comment, belongs to the ticket-lifecycle recipe),mail_outbound_message(128-140),mail_client_review(143-156),mail_deadline_extended(159-177),mail_closed(180-193);portal_link/admin_link(42-47).alchemy_labs/backend/services/service_desk/employee_prefs.py(260 lines, whole file in scope):DEFAULT_PREFS(29-43);_merged(80-102);get_prefs/save_prefs/update_prefs(105-139);public_view(142-155); pairing (163-221);unlink_telegram/find_user_by_chat_id/unlink_by_chat_id/telegram_chat_id(224-260).alchemy_labs/backend/services/service_desk/reminders.py(124 lines, whole file in scope):run_desk_reminders(26-74);_remind_ticket(77-124).alchemy_labs/backend/services/service_desk/telegram_bot.py: this recipe's slice only,is_configured/deep_link/send_message/ticket_buttons(108-212); 375-793 is the inbound command/reply surface, out of scope (When to use).alchemy_labs/backend/services/service_desk/roles.py:desk_role/served_workspace_ids(63-104);agent_user_ids(132-139).alchemy_labs/backend/api/routers/service_desk_router.py:_require_employee/DeskEmployee(93-97, 111);_prefs_view/get_my_prefs/put_my_prefs/start_pairing/unlink_telegram/test_telegram(930-1004).alchemy_labs/backend/services/service_desk/ticket_service.py:Effects(44-84, the queueing this recipe's callers use);_mail_message(223-233); the emit/mail call sites (74-79, 371-378, 722, 895, 916, 942, 965).alchemy_labs/backend/schemas/service_desk_schemas.py:EventPrefsUpdate/QuietHoursUpdate/EmployeePrefsUpdate(389-417).alchemy_labs/backend/models/service_desk_model.py:DeskNotificationDelivery(371-416, table + indexes).alchemy_labs/backend/services/scheduled_jobs.py: thedesk_remindersjob registration (447-459); therun_desk_reminderswrapper (565-573).alchemy_labs/backend/migrations/versions/20260904_0001_service_desk_core.py(creates the table, 210-234) and20260905_0002_desk_telegram_reply.py(addsprovider_message_id, whole file).- Tests:
alchemy_labs/backend/tests/test_desk_notifications.py(418 lines, whole file in scope) and its fixtures inalchemy_labs/backend/tests/desk_support.py(mail_log~179,telegram_log~214,frozen_clock~254) andalchemy_labs/backend/tests/conftest.py(test_engine/db_session, 54-146).
Deviations of the reference from an ideal implementation, all dated 2026-09-12: the one failing assertion under Tests; the lost deferred-context under Gotchas; no admin/debug route for desk_notification_deliveries; no rate limit on pairing/test; the pairing/chat-id lookups are full table scans (acceptable only at current agency scale, by the module's own docstring).
Acceptance checks
Run from alchemy_labs/backend at 62ba4fc (or an app built to this recipe). Expected observations are exact, as measured 2026-09-12.
createdb prima_desk_scratch(an empty databasealembichas never touched) thenTEST_DATABASE_URL=postgresql://localhost:5432/prima_desk_scratch DATABASE_URL=postgresql://localhost:5432/prima_desk_scratch SYNC_DATABASE_URL=postgresql://localhost:5432/prima_desk_scratch ENVIRONMENT=development WEBAUTHN_RP_ID_BACKFILL=localhost python -m pytest tests/test_desk_notifications.py -q --no-cov -o addopts="" -p no:cacheproviderprints13 passed, 1 failed, the failure beingtest_fanout_prefers_telegram_and_falls_back_to_email.grep -c "^def test_" tests/test_desk_notifications.pyprints14;grep -c "@pytest.mark.critical" tests/test_desk_notifications.pyprints5.- Separately, on a fresh empty database,
SYNC_DATABASE_URL=postgresql://localhost:5432/<scratch2> DATABASE_URL=postgresql://localhost:5432/<scratch2> ENVIRONMENT=development WEBAUTHN_RP_ID_BACKFILL=localhost python -m alembic upgrade headcompletes with no error, ending at headprima_device_codes_001, and its INFO lines show it pass throughservice_desk_core_001then, afterdesk_domain_login_001,desk_telegram_reply_001in that order. grep -n "def ticket_buttons" -A1 services/service_desk/telegram_bot.pyshows line194and confirms the Accept/Ask/Open buttons are built as aninline_keyboarddict, never string-concatenated into any text.grep -n 'email_text = f' services/service_desk/desk_notification.pyshows line305, the one place the admin link is appended to outbound text, insideif ticket is not None:guardingemail_textonly, never the Telegramtextvariable, confirming the Tests-section mismatch is in the reference code, not a misreading of the test.grep -c "except Exception" services/service_desk/desk_notification.pyprints7, consistent with "never raises" being enforced by explicit per-seam catches (emit's outer catch and its own rollback's catch,_deliver's Telegram-config and e-mail-send catches,emit's inline first-message read,release_deferred's inline Telegram/e-mail catches) rather than by construction.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.