Spaces:
Running
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.
emit_for_event(conn, record, *, before, extra)β called fromstate._apply_event(inside the live durable transaction, afterrepo_transitions.append) and fromintake.resolve. A_RESOLVERStable maps event name β target user(s). Self-suppression drops a target equal to the actor, except where a resolver setskeep_self.notify_flag_reply(...)β called fromservices/segments/save.pyafter a successful save (segment saves write the bucket, not SQLite, so this opens its owndurable_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 publicAnnouncementwire shape (id/title/body/created_at). - Owner compose/manage (
routes/admin/announcements.py, all gateannouncements.send):GET /api/admin/announcements(active + revoked, theAnnouncementAdminshape),POST /api/admin/announcements(compose, validates viaAnnouncementCreate),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 thenotify.email_subscriptionscapability β shown to everyone incl. anonymous) that opensEmailPrefsModal.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(migration0022) +repo_email_subscriptionsβ keyed by normalized email, prefs as a JSON blob, stablemanage_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 publicGET /api/email-unsubscribe. - Sender:
services/email/(Flask-free; namedservices.emailto avoid shadowing the stdlibemail) β Jinjatemplates/(one per event extendingbase.html, greeting "Assalamu Alaikum"),send.py(Brevo transactional REST API over HTTPS βPOST /v3/smtp/email, authBREVO_API_KEY, From the Brevo-verifiedEMAIL_FROM_ADDRESS; fire-and-forgetThreadPoolExecutor. HF Spaces block outbound SMTP on every port, so a directsmtplibsend silently never delivers β hence HTTPS. WhenBREVO_API_KEYis absent it logs the rendered email instead of sending so dev exercises the flow), andemit.py(per-event recipient resolution). - Event hooks (best-effort, never break the write):
reciter.published+reciter.alignment_completedinstate._apply_event;reciter.ts_regeneratedintimestamps_jobs._regenerate_timestamps_on_released; the GH cut incut_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 (precedenceriwayah_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 useconfig.EMAIL_APP_BASE_URL(INSPECTOR_PUBLIC_BASE_URL, localhost in dev). - Digest (burst control). The two high-volume events β the
recitation_publishedscope andtimestamps_regeneratedβ do not send immediately.emit.pybuffers oneemail_digestrow per matched recipient (migration0023+repo_email_digest; recipient resolution unchanged, soall/selectedboth work) andservices/email/digest.pysweeps that buffer (start_flush_daemon, ~60 s, opt-outINSPECTOR_EMAIL_DIGEST_FLUSH=0). Each(email, event_kind)group flushes as one email once its tumbling window (opened by the earliest buffered row) ages pastEMAIL_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 ridesdurable_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 throughtransition().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 thesendseam); 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-edgedue_groups, oldest-firstitems_for, id-scopeddelete_idskeeps post-read rows.