quranic-universal-audio / docs /reference /notifications.md
hetchyy's picture
deploy: inspector prod @ 2ce61c0
86bc0aa verified
|
Raw
History Blame Contribute Delete
20.2 kB

Per-user notifications

The Dashboard "My Notifications" rail β€” events that happened to the signed-in user, distinct from the global, identity-redacted "Recent activity" feed. Per-user, dismissable, archived-on-dismiss.

Rail placement: right column, under the admin-dashboard button, above Recent activity (tabs/dashboard/components/NotificationsRail.svelte, rendered inside ActivityRail.svelte's .rail-wrap). Hidden for anonymous users.

Click-through (event-aware redirects)

Each notification card shows title + body inline (no expand) with a redirect icon (β†—) beside the dismiss/restore icon; its tooltip is the destination, so the redirect is transparent on hover (NotificationsRail.navTarget):

Event Tooltip Destination
reciter.alignment_completed, reciter.claimed "Review in Segments" gotoSegments(slug) β€” Segments tab, reciter loaded (mirrors the post-claim redirect)
reciter.marked_ready "Review submission" gotoSegments(slug, {openMarkReadyReview}) β€” Segments tab + the read-only MarkReadyReviewModal (the reviewer's free-text notes, no checklist)
flag.reply, flag.created, flag.replied "Open flagged segment" gotoSegments(slug, {openFlagged, focusFlaggedUid}) β€” Segments tab + Flagged accordion open + scrolled to the flagged segment
ts_report.created, ts_report.resolved "View reported verse" gotoTimestamps(slug, payload.verse_key) β€” Timestamps tab, that reciter loaded + playhead seeked to the verse (categorized report / its resolution)
request.received (none) informational β€” no click-through; a type pill (payload.kind) names the request kind
everything else "View reciter" dashboard detail modal (openDetail), when the reciter is still catalogued

A cardBadge(n) helper renders a small type pill next to the title: request.received β†’ the kind (Edit existing combo / New riwāyah Β· style / New reciter), reciter.marked_ready β†’ "Has notes", flag.created β†’ "Flag Β· comment", flag.replied β†’ "Flag Β· reply", ts_report.created β†’ "Timestamps Β· report", ts_report.resolved β†’ "Timestamps Β· resolved".

For ts_report.created, the card body shows the reporter's login (from payload.author_login, or "an anonymous listener") only to callers holding timestamps.see_reporter_identity (owner-default) β€” otherwise just the verse + category. The FE gate lives in NotificationsRail.cardBody.

The flag deep-link is a cross-tab handoff: gotoSegments writes pendingSegmentsDeepLink (lib/utils/goto-segments.ts); ValidationPanel consumes it once the reciter's flagged segments load β€” opens the __flagged__ accordion and scrolls the [data-flag-uid=...] card into view. It waits for the target uid to appear so a stale (previous-reciter) flag list never triggers it. flag.reply / flag.created / flag.replied notifications carry payload.segment_uid for this. The openMarkReadyReview variant is consumed by SegmentsTab (not ValidationPanel) β€” once the reciter is switched in it mounts MarkReadyReviewModal and clears the pending intent.

Model

Notifications are materialized β€” one notifications row per (target user, source event), written at emit time, NOT derived on read. The target user is only reliably knowable at emit time: the pending request is archived and the claim's assignee is cleared in the same transaction that fires the event.

SQLite table notifications (migration 0019_notifications.sql):

Column Notes
id PK
hf_user_id target user (FK users)
event source event name, or flag.reply
slug delivery slug to link to; NULL for slugless intake
title frozen collapsed-view summary (always names the reciter)
body frozen detail (admin reason / reply text); nullable
payload JSON extras (e.g. segment_uid); nullable
source_key dedup + provenance: transition id, or flag:<slug>:<uid>:<at_utc>
created_at / seen_at / dismissed_at ISO-8601 UTC; dismissed_at IS NOT NULL β‡’ archived

Dedup: UNIQUE(hf_user_id, source_key) + INSERT OR IGNORE β€” a re-driven transaction or retried save can't double-insert. Retention: prune_for_user (called from create) keeps the newest 200 dismissed rows per user; active rows are never auto-pruned.

Repo: services/db/repo_notifications.py (caller owns the txn). Routes: routes/auth/notifications.py β€” GET /api/me/notifications (active + unread, marks seen), GET .../archived, POST .../<id>/dismiss, POST .../<id>/restore (all signed-in + owner-scoped). /api/me carries notifications_unread for the first-load badge.

Emission

services/notifications/emit.py has two entry points, one per source write-path. Both are best-effort β€” wrapped in try/except-log so a notification failure never rolls back the motivating transition or save.

  1. emit_for_event(conn, record, *, before, extra) β€” called from state._apply_event (inside the live durable transaction, after repo_transitions.append) and from intake.resolve. A _RESOLVERS table maps event name β†’ target user(s). Self-suppression drops a target equal to the actor, except where a resolver sets keep_self.
  2. notify_flag_reply(...) β€” called from services/segments/save.py after a successful save (segment saves write the bucket, not SQLite, so this opens its own durable_transaction).

Every title names its reciter, resolved once via catalog.display_name(slug) (or the proposed name in the request payload for slugless intake).

Event β†’ target

Event Target Copy
reciter.request_rejected_soft pending requester "Your request for X was sent back" + reason
reciter.request_rejected_hard pending requester "Your request for X was discarded" + reason
reciter.alignment_completed pending requester (non-auto-claim only) "X is ready for review"
reciter.claimed (auto-claim fold) requester (keep_self) "You've been assigned to X"
claim.force_released prior assignee (before.assignee_hf_id) "Your review of X was released β€” it hadn't been active for a while"
request.intake_returned requester (requests.requester_id) "Your submission for X was sent back" + reason
request.intake_discarded requester "Your submission for X was discarded" + reason
flag.reply original flagger "New reply on a segment you flagged in X" + reply text
reciter.marked_ready review-alert recipients (only when a comment box is non-empty) "X marked ready β€” reviewer left notes" + the notes

Owner review alerts

Three event types fan out to review-alert recipients β€” everyone holding the notifications.receive_review_alerts capability (owner-default-on, delegatable to maintainers from the Permissions tab). emit._review_alert_recipients() β†’ capabilities.users_with_capability(...). Per-target self-suppression drops the acting user (an owner's own request / mark-ready / flag never notifies them).

Event Fired from Copy
request.received notify_owners_new_request β€” called from the slug-based edit-request route AND intake.submit (NOT a reciter.requested resolver, which re-fires on ingest) "New request Β· X" + a body detail; payload.kind = existing_combo_edit / existing_reciter_new_combo / new_reciter
reciter.marked_ready _r_marked_ready resolver (only when comment_checks or comment_issues is non-empty) "X marked ready β€” reviewer left notes" + the notes; payload.openMarkReadyReview
flag.created / flag.replied notify_owners_flag_activity β€” from the segment-save flow for set / followup flag ops "New flag on X" / "New reply on a flag Β· X" + surah:ayah β€” comment; payload.segment_uid
ts_report.created notify_owners_ts_report β€” from the public Timestamps-tab report route, once per new report (re-submits of the same category+target don't re-notify) "Timestamps issue reported Β· X" + verse_key Β· category; payload.verse_key + category + report_id + author_id / author_login (null for anonymous). source_key=tsreport:<slug>:<report_id>
ts_report.resolved notify_reporter_ts_report_resolved β€” from the resolve route, to the (signed-in) reporter only; anonymous reporters are skipped "Your timestamps report for X was resolved" + a generic thank-you + the owner's optional comment; payload.verse_key + report_id. source_key=tsreportresolved:<slug>:<report_id>

Auto-archive. request.received cards are informational. When the reciter reaches awaiting_review (the reciter.alignment_completed event), the request has been handled, so emit._archive_request_alerts(slug) dismisses every owner's card for it β€” repo_requests.ids_for_slug(slug) β†’ repo_notifications.dismiss_by_source_key("request:<id>") (one source_key shared across all recipients, so one call clears the whole fan-out). The intake row's slug is back-filled at ingest, so both request paths are reachable by slug.

The old per-admin Requests-tab unviewed badge (entry-button dot, tab count, per-row dot, /api/admin/requests/unviewed-count, the request_views writer) was retired in favour of these alerts β€” "new request" awareness lives only on the rail now.

The requester for the reject/alignment events is captured in _apply_event before the handler runs (the pending row is archived mid-handler). The auto-claim fold is marked with payload.notify_auto_claim so the reciter.claimed resolver distinguishes it from a manual self-claim (which notifies no one).

Deliberately excluded: claim.reassigned (event unused), reciter.published (already on the public activity rail), reciter.merge_rejected, and all admin/catalog/self events.

Announcements (global broadcast)

The same rail also shows announcements β€” an owner-composed broadcast that reaches everyone, including signed-out visitors. Unlike per-user notifications (one materialized row per target), an announcement is a single global row; there is no per-user fan-out, so it also reaches anonymous users who have no hf_user_id.

Model. SQLite table announcements (migration 0021_announcements.sql): id, title, body (nullable), author_hf_user_id + author_login (provenance, no FK), created_at, revoked_at (NULL β‡’ active). An announcement stays active until an owner revokes it. Repo services/db/repo_announcements.py (create / list_active / list_all / revoke); service services/announcements.py (Flask-free; writes open their own durable_transaction, validates title non-empty).

Routes.

  • Public read: GET /api/announcements (routes/announcements.py) β€” ungated, anonymous-reachable (mirrors /api/static/*); serves the active rows as the public Announcement wire shape (id/title/body/created_at).
  • Owner compose/manage (routes/admin/announcements.py, all gate announcements.send): GET /api/admin/announcements (active + revoked, the AnnouncementAdmin shape), POST /api/admin/announcements (compose, validates via AnnouncementCreate), POST /api/admin/announcements/<id>/revoke. The two POSTs also carry @require_same_origin.

announcements.send is a G_ADMIN capability, owner-only by default but toggleable (an owner can delegate to maintainers from the Permissions tab).

Dismiss / seen β€” client-side only. There is no server-side per-user dismiss state. The browser tracks it in localStorage (insp_dismissed_announcements, insp_seen_announcements); dismissing hides an announcement permanently on that browser. The store (tabs/dashboard/stores/announcements.svelte.ts) polls the public route on the same 30s visibility-aware cadence and computes unread against a page-load snapshot of the seen-set, so a freshly-arrived announcement reads as "new" for the session without the always-visible rail clearing it instantly.

Rail rendering. Announcements render as normal notification cards (not a separate group): in the Active view they merge with personal notifications, newest-first, styled identically β€” each with a dismiss βœ• (no nav-target, no archive/restore). The whole rail (NotificationsRail.svelte, mounted unconditionally inside the public ActivityRail) shows for anonymous users whenever β‰₯1 announcement is active; the Active/Archive toggle + personal list stay signed-in-only. Compose UI: the Admin β†’ Announcements tab (AnnouncementsCompartment.svelte).

Email notifications

A second, opt-in channel alongside the in-app rail: no-reply emails for catalog

  • workflow events. The rail header carries an "Email notifs" button (NotificationsRail.svelte, gated on the notify.email_subscriptions capability β€” shown to everyone incl. anonymous) that opens EmailPrefsModal.svelte (tabs/dashboard/components/).

Identity = the email address, not the HF account. Subscriptions are keyed by the typed email so anonymous visitors can subscribe. The row carries an optional hf_user_id (set when the saver is signed in β€” used only to match the request_aligned event to its requester). Per the product decision there is no verification (saving activates immediately) and no HF auto-seed (the field is user-typed; the HF cookie carries no email).

Manage token. On first save the server mints one stable manage_token per email (secrets.token_urlsafe). It is the only secret guarding the row and does double duty: the one-click unsubscribe link (GET /api/email-unsubscribe?token=, turns every event off) and the email "manage" deep-link (<app>/?manage=<token> β†’ NotificationsRail opens the modal seeded by token). The FE caches it in localStorage; the modal re-fetches by token on open so it stays consistent with an out-of-band unsubscribe (synced on open, not pushed).

Model (EmailPreferences in qua_shared/schemas/wire/email_preferences.py, codegen'd β†’ FE EmailPrefs). One destination email, six event settings, and two shared selections reused across events (pick once, applies to both):

Field Type Event
request_aligned bool A request you submitted finishes alignment
recitation_published off/all/selected A recitation is published
timestamps_regenerated off/all/selected A reciter's timestamps are regenerated
github_release bool A new GitHub release is cut
riwayah_new_recitation bool New recitation in a followed riwayah
riwayah_first_available bool A followed riwayah becomes available (one-time)
reciters reciter_id[] shared target for every selected-scope event
riwayahs slug[] shared follow-list for both riwayah events

An enabled event whose backing selection is empty is a no-op; the modal warns inline rather than blocking save. The reciter/riwayah option sets are derived client-side from the loaded catalog. Reusable primitives: lib/components/Segmented.svelte + lib/components/ChipMultiSelect.svelte; envelope glyph at lib/icons/mail.svg.

Backend.

  • Store: email_subscriptions (migration 0022) + repo_email_subscriptions β€” keyed by normalized email, prefs as a JSON blob, stable manage_token.
  • Routes: routes/auth/email_preferences.py β€” GET/POST /api/me/email-preferences (capability-gated, not 401 for anonymous; GET resolves by HF cookie β†’ ?token= β†’ defaults; POST @require_same_origin, mints+echoes the token) and the public GET /api/email-unsubscribe.
  • Sender: services/email/ (Flask-free; named services.email to avoid shadowing the stdlib email) β€” Jinja templates/ (one per event extending base.html, greeting "Assalamu Alaikum"), send.py (Brevo transactional REST API over HTTPS β€” POST /v3/smtp/email, auth BREVO_API_KEY, From the Brevo-verified EMAIL_FROM_ADDRESS; fire-and-forget ThreadPoolExecutor. HF Spaces block outbound SMTP on every port, so a direct smtplib send silently never delivers β€” hence HTTPS. When BREVO_API_KEY is absent it logs the rendered email instead of sending so dev exercises the flow), and emit.py (per-event recipient resolution).
  • Event hooks (best-effort, never break the write): reciter.published + reciter.alignment_completed in state._apply_event; reciter.ts_regenerated in timestamps_jobs._regenerate_timestamps_on_released; the GH cut in cut_release.complete. Each sits past the existing idempotency guard so a webhook+poll double-fire can't double-send. A publish collapses to one email per address (precedence riwayah_first_available > riwayah_new_recitation > recitation_published); first-in-riwayah is computed from the released set in the catalog. Per-recipient unsubscribe links mean one message per address (no BCC); fan-out is best-effort with no retry β€” acceptable at current scale.
  • Links: release emails point at the GH releases page (config.EMAIL_GH_RELEASES_URL); all events link the Space (config.EMAIL_SITE_URL). Functional links use config.EMAIL_APP_BASE_URL (INSPECTOR_PUBLIC_BASE_URL, localhost in dev).
  • Digest (burst control). The two high-volume events β€” the recitation_published scope and timestamps_regenerated β€” do not send immediately. emit.py buffers one email_digest row per matched recipient (migration 0023 + repo_email_digest; recipient resolution unchanged, so all/selected both work) and services/email/digest.py sweeps that buffer (start_flush_daemon, ~60 s, opt-out INSPECTOR_EMAIL_DIGEST_FLUSH=0). Each (email, event_kind) group flushes as one email once its tumbling window (opened by the earliest buffered row) ages past EMAIL_DIGEST_WINDOW_MINUTES (60). Per-event, never cross-event β†’ at most two batched emails per recipient per window. One buffered reciter reuses the singular template; two or more use <event_kind>_digest.html. Buffering rides durable_transaction (nesting-safe): inside the publish transition it adds no extra bucket upload; the TS path is its own top-level write. The riwayah-follow flavors stay immediate (lower frequency). Flush is send-then-delete (a rare mid-flush crash re-sends rather than drops β€” consistent with best-effort).

Tests

  • tests/db/test_repo_announcements.py β€” announcement create / list_active / revoke (active⇄all, no-op double-revoke).
  • tests/routes/test_route_announcements.py β€” public read anon-reachable, compose gated owner-only (maintainer/contributor 403, anon 401), revoke drops from the public list, empty title 400.
  • tests/notifications/test_emit_resolvers.py β€” resolver target correctness, self-suppression, SYSTEM-actor alignment, auto-claim keep-self, dedup, flag-reply self-suppression.
  • tests/db/test_repo_notifications.py β€” retention prune, dedup.
  • tests/routes/test_route_notifications.py β€” auth, list/mark-seen, dismiss/restore, owner-scoping.
  • tests/services/test_state_request_events.py::test_reject_soft_notifies_requester β€” end-to-end through transition().
  • tests/db/test_repo_email_subscriptions.py β€” email-keyed upsert, token + created_at preserved on update, unsubscribe turns every event off.
  • tests/routes/test_route_email_preferences.py β€” anonymous reachable (no 401), token minted/echoed, GET-by-cookie vs GET-by-token, bad email 400, unsubscribe.
  • tests/services/test_email_emit.py β€” per-event recipient resolution, scope filtering, single-email-per-publish precedence (captured at the send seam); the two scope events buffer (no immediate send) while riwayah stays immediate.
  • tests/services/test_email_digest.py β€” flush windowing, singleβ†’singular vs manyβ†’digest template, reciter dedup, buffer deletion, per-event isolation.
  • tests/db/test_repo_email_digest.py β€” window-edge due_groups, oldest-first items_for, id-scoped delete_ids keeps post-read rows.