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.