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.