Spaces:
Running
Running
File size: 21,328 Bytes
980ba90 773d586 86bc0aa 773d586 980ba90 773d586 86bc0aa 564d148 86bc0aa 564d148 86bc0aa 773d586 980ba90 773d586 980ba90 773d586 86bc0aa 773d586 980ba90 4d1a2e5 4bcd818 4facd9a 4bcd818 4facd9a 4bcd818 a634490 4bcd818 85000d6 4bcd818 980ba90 4d1a2e5 980ba90 4bcd818 85000d6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 | # 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) |
| `owner_new_request` | bool | (owner-gated, see below) A new request/submission arrives |
| `reciters` | `reciter_id[]` | shared target for every `selected`-scope event |
| `riwayahs` | slug[] | shared follow-list for both riwayah events |
**`owner_new_request` is capability-gated, not open self-service.** Unlike the
other six fields, its row in `EmailPrefsModal` only renders for a holder of the
`notify.owner_request_emails` capability (owner-default, delegatable to
maintainers β mirrors `notifications.receive_review_alerts` but for the email
channel). The route also re-checks server-side on save (silently drops the flag
to `False` for a non-holder rather than 403ing the whole save), and the emitter
(`emit_owners_new_request`) re-checks *again* at send time against the live
capability set, so a since-revoked delegate stops receiving it without anyone
touching their subscription row. Fires immediately (not digested) from
`services/notifications/emit.py::notify_owners_new_request` β the same call site
that fans the in-app `request.received` rail card out to review-alert
recipients β for both request-creation paths (slug-based edit request, slugless
intake submit). Self-suppressed for the requester. Template:
`services/email/templates/owner_new_request.html`.
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.
|