Slice 6: notifications per §15
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+131
-71
@@ -186,6 +186,100 @@ posting, arbiter-only merge, contributor withdraw with the
|
||||
of a public PR, and the full §10.9 conflict-replay path including
|
||||
the auto-close of the original PR on the resolution PR's merge.
|
||||
|
||||
### Slice 6 — shipped
|
||||
|
||||
Notifications per §15 in full, end-to-end against the local Gitea.
|
||||
|
||||
The producer-side chokepoint lives in
|
||||
[`backend/app/notify.py`](../backend/app/notify.py). Every bot
|
||||
`_log` call drops into `notify.fan_out_from_action`, which upserts
|
||||
the actor's `watches` row per §15.6's substantive-gesture rule and
|
||||
runs the §15.1 routing table to insert zero-or-more `notifications`
|
||||
rows. Chat-message inserts (the second writer surface, since chat
|
||||
doesn't flow through the bot) call `notify.fan_out_chat_message`
|
||||
from inside `chat.append_user_message` — same chokepoint shape, one
|
||||
place to read the routing. The graduation orchestrator's `_audit`
|
||||
helper folds into the same fan-out so `graduate_start` /
|
||||
`graduate_complete` ride the chokepoint too.
|
||||
|
||||
§15.4 email lives in [`backend/app/email.py`](../backend/app/email.py).
|
||||
The SMTP adapter wraps Python's `smtplib`; when `SMTP_HOST` is unset
|
||||
it falls back to logging the envelope (and appending it to an
|
||||
in-memory `_SENT` buffer the integration tests read from). The
|
||||
per-category dispatch consults the recipient's toggles, holds
|
||||
during §15.8 quiet hours, and on quiet-hours window-end the
|
||||
`flush_pending` pass bundles into a single "Activity while you were
|
||||
away" mail when more than `EMAIL_BUNDLE_THRESHOLD` accumulated.
|
||||
One-click unsubscribe is a signed token over `(user_id, category)`;
|
||||
the bounce webhook flips `email_opt_out_all` on the user (new
|
||||
column added by migration 008).
|
||||
|
||||
§15.5 digest lives in [`backend/app/digest.py`](../backend/app/digest.py)
|
||||
as a `DigestScheduler` mirroring `cache.Reconciler`'s shape. The
|
||||
`run_tick` function is the test seam — integration tests drive
|
||||
ticks synchronously, production runs the loop on a `DIGEST_TICK_SECONDS`
|
||||
cadence (default 3600s). Each tick releases held emails, decays
|
||||
§15.6 `watching` rows whose `last_participation_at` is >90 days
|
||||
old, and assembles digests for users whose cadence window has
|
||||
rolled over per `notification_digests.period_end`.
|
||||
|
||||
§15.2 / §15.3 / §15.7 / §15.8 surface as the twelve endpoints in
|
||||
[`backend/app/api_notifications.py`](../backend/app/api_notifications.py)
|
||||
plus the §15.7 chat-seen advance on `api_branches` and the PR
|
||||
seen-cursor advance on `api_prs` — both extended to call
|
||||
`notify.reconcile_seen_advance` so visit-advances-cursor closes the
|
||||
inbox-read loop per §15.7. The `/api/notifications/stream` SSE
|
||||
handler holds a per-user subscriber queue keyed by user_id; one
|
||||
event per browser tab, all subscribers for a user receive every
|
||||
event so the badge counter stays in lockstep across tabs.
|
||||
|
||||
| Method | Path | § |
|
||||
| ------ | ------------------------------------------------- | ------- |
|
||||
| GET | `/api/notifications` | §15.2 |
|
||||
| POST | `/api/notifications/{id}/read` | §15.2 |
|
||||
| POST | `/api/notifications/read` | §15.2 |
|
||||
| GET | `/api/notifications/stream` | §15.3 |
|
||||
| GET | `/api/watches` | §15.6 |
|
||||
| POST | `/api/rfcs/{slug}/watch` | §15.6 |
|
||||
| POST | `/api/rfcs/{slug}/branches/{branch}/chat-seen` | §15.7 |
|
||||
| GET | `/api/users/me/notification-preferences` | §15.4/5 |
|
||||
| POST | `/api/users/me/notification-preferences` | §15.4/5 |
|
||||
| GET | `/api/users/me/quiet-hours` | §15.8 |
|
||||
| POST | `/api/users/me/quiet-hours` | §15.8 |
|
||||
| POST | `/api/users/{id}/notification-mute` | §15.8 |
|
||||
| DELETE | `/api/users/{id}/notification-mute` | §15.8 |
|
||||
| GET | `/api/email/unsubscribe` | §15.4 |
|
||||
| POST | `/api/webhooks/email-bounce` | §15.4 |
|
||||
|
||||
On the frontend, `App.jsx` grew a header badge button (`📮` glyph
|
||||
with a 99+-capped unread count) that opens the inbox overlay. The
|
||||
overlay is `Inbox.jsx` — three filter chips (Unread only, RFC,
|
||||
Category) plus a Bundle toggle and a "Mark all read (under filter)"
|
||||
action. The badge subscribes to the SSE stream alongside the
|
||||
overlay so they share a counter. `ToastHost.jsx` renders personal-
|
||||
direct toasts and live-view toasts (an event firing on the slug
|
||||
the URL points at), capped at four visible at a time with auto-
|
||||
dismiss after a short interval.
|
||||
|
||||
Slice 6 ships covered by
|
||||
[`backend/tests/test_notifications_vertical.py`](../backend/tests/test_notifications_vertical.py) —
|
||||
seventeen integration tests covering the producer-side fan-out for
|
||||
the propose/merge/decline chain, §15.6 auto-watch on first
|
||||
interaction, the §15.2 inbox listing with filter chips, the §15.7
|
||||
chat-seen reconciler, the §15.8 per-user mute and the per-RFC mute,
|
||||
the §15.4 email-bounce webhook, the `/email/unsubscribe` signed-URL
|
||||
path, the §15.8 quiet-hours hold, the §15.5 digest's emit-then-skip
|
||||
behavior across two consecutive ticks, preferences and quiet-hours
|
||||
round-trips, the explicit-watch override that prevents auto-downgrade,
|
||||
and the SSE subscriber/broadcast substrate. The full Slices 1–6 test
|
||||
suite is 62/62 green.
|
||||
|
||||
The schema needed one small migration —
|
||||
[`008_email_opt_out.sql`](../backend/migrations/008_email_opt_out.sql)
|
||||
adds the `email_opt_out_all` column to `users` for the bounce
|
||||
webhook. Topic 13 settled the rest of the §5 surface before the
|
||||
build started, so no further migrations were needed.
|
||||
|
||||
### Slice 5 — shipped
|
||||
|
||||
Graduation per §13 in full. The §13.3 five-step transactional sequence
|
||||
@@ -416,86 +510,52 @@ spec:
|
||||
|
||||
## Next slice
|
||||
|
||||
**Slice 6: notifications per §15.**
|
||||
**Slice 7: the §14 chrome.**
|
||||
|
||||
Every other vertical now produces signals: propose, claim, merge,
|
||||
graduate, body edits, manual flushes, PR open/withdraw/merge,
|
||||
review threads, conflict-replay, super-draft chat. Slice 6 builds
|
||||
the inbox, the fan-out, the digest, and the email loop that turn
|
||||
those signals into a contributor's surface. The §5 schema already
|
||||
carries the notifications, watches, branch_chat_seen,
|
||||
notification_user_mutes, and notification_digests tables; Topic 13's
|
||||
session settled the producer-side rules per §15.1 (the signal-surface
|
||||
stack), the §15.2 inbox grouping, §15.3 badges and toasts, §15.4
|
||||
email categories, §15.5 digest cadence, §15.6 watch/subscription,
|
||||
§15.7 unread mechanism, §15.8 do-not-disturb, and §15.9 attribution.
|
||||
With Slice 6 shipped, every structural and notification beat the
|
||||
framework commits to is live: propose, claim, super-draft body
|
||||
editing, the §10 PR flow against both repo shapes, graduation, and
|
||||
the §15 inbox/email/digest stack. What remains for v1 is the chrome
|
||||
that wraps the whole thing — the landing page that brings an
|
||||
unauthenticated visitor in, the `/philosophy` route that surfaces
|
||||
[`PHILOSOPHY.md`](../PHILOSOPHY.md) verbatim, the persistent About
|
||||
link in the header per §14.3, plus the natural neighbors that
|
||||
Slice 6 left as API-only and that §19.2 names as candidates:
|
||||
|
||||
Slices 1–5 left this clean: every user gesture goes through the
|
||||
bot wrapper and lands an `actions` row with the underlying actor.
|
||||
The producer-side hook is "after a write succeeds, evaluate watches
|
||||
and fan-out notification rows." The consumer-side hook is the
|
||||
header badge, the inbox panel, the toast surface, and the per-row
|
||||
read-state machinery.
|
||||
- **The notification-settings surface** — the actual UI for the
|
||||
preferences/quiet-hours/mute endpoints Slice 6 wired. Topic 13
|
||||
settled the schema and the per-category rules; the surface
|
||||
where a contributor finds the per-category email toggles, the
|
||||
digest cadence dropdown, the quiet-hours editor, the watches
|
||||
overview, and the per-user mute list is the natural follow-on.
|
||||
Likely lives at `/settings/notifications` (the link Slice 6's
|
||||
emails already point at).
|
||||
- **The admin neighborhood.** §19.2's "Admin surfaces" candidate.
|
||||
Role management, the §6.2 app-wide write-mute, the audit-log
|
||||
viewer, the graduation-readiness queue. Topics 12 and 13 both
|
||||
expanded the admin's repertoire without giving it a centralized
|
||||
home; Slice 7 picks the framing.
|
||||
- **Landing page polish.** Slice 1 stood up a minimal landing for
|
||||
the unauthenticated path; §14 commits a richer shape — what the
|
||||
framework is, why it exists, what the visitor's first read should
|
||||
be, and the sign-in affordance.
|
||||
- **The `/philosophy` route.** [`PHILOSOPHY.md`](../PHILOSOPHY.md)
|
||||
rendered inline, reachable from the header on every page, so the
|
||||
reader can return to the framing without leaving the app.
|
||||
|
||||
What Slice 6 owns specifically:
|
||||
What Slice 7 does NOT own:
|
||||
|
||||
- **The producer fan-out.** Every `actions` row whose event maps to a
|
||||
§15 signal produces zero-or-more `notifications` rows by joining
|
||||
against `watches` and applying the §15.1 priority rules. The
|
||||
fan-out lives as a small module that the bot wrapper invokes
|
||||
inline after each write — same chokepoint shape Slice 1's
|
||||
`_log` uses.
|
||||
- **The §15.2 inbox.** `GET /api/notifications` with the
|
||||
`unread` / `rfc_slug` / `category` / `bundled` filter chips,
|
||||
`POST /api/notifications/<id>/read` for per-row marking,
|
||||
`POST /api/notifications/read` for the bulk filter mark, and the
|
||||
SSE `GET /api/notifications/stream` that backs the live badge.
|
||||
- **The §15.3 surface.** The header badge counter (live via the SSE),
|
||||
the toast on personal-direct events while the user is active, and
|
||||
the ambient signal — a colored dot per row on the §7 catalog
|
||||
pointing at watched RFCs with unseen activity.
|
||||
- **The §15.4 email loop.** Per-category opt-in/out preferences on
|
||||
the users table (already in the schema), the `/api/users/me/notification-preferences`
|
||||
endpoints, the email-send adapter that routes a notification's
|
||||
category through the user's category toggle, and the
|
||||
`POST /api/webhooks/email-bounce` receiver that sets the global
|
||||
opt-out. Plus the `GET /api/email/unsubscribe` signed-URL
|
||||
one-click flow.
|
||||
- **The §15.5 digest.** A scheduled-job that runs daily and weekly
|
||||
to roll up unseen notifications into a single email, with the
|
||||
`notification_digests` table tracking what was included so the
|
||||
next digest skips what already shipped.
|
||||
- **The §15.6 watch model.** Auto-watch on first interaction with
|
||||
an RFC, the per-row state column (`watching` / `following` /
|
||||
`muted`), the 90-day auto-decay for unset rows, and the explicit
|
||||
`POST /api/rfcs/<slug>/watch` overrides.
|
||||
- **The §15.7 unread mechanism.** Advance the `branch_chat_seen`
|
||||
cursor on every branch read, reconcile inbox notifications to
|
||||
read when their underlying surface is consumed.
|
||||
- **The §15.8 do-not-disturb.** Quiet-hours config on the user, the
|
||||
per-user notification mute list, the orthogonality vs §6.2's
|
||||
app-wide write-mute.
|
||||
|
||||
What Slice 6 does NOT own:
|
||||
|
||||
- The §14 chrome polish (still Slice 7).
|
||||
- The §12 30/90 branch-hygiene timers (still Slice 8).
|
||||
- The §16 deferred items.
|
||||
- New §15 capabilities — Slice 6 shipped the surface; settings UI
|
||||
is exposure of what's already there, not new behavior.
|
||||
|
||||
The carryovers Slice 6 inherits — the existing `actions` audit log
|
||||
(every signal traces back to a row there per §15.9), the SSE
|
||||
machinery from Slices 2 and 5 (chat-stream and graduate-progress
|
||||
respectively), and the §5 schema's notification tables (already
|
||||
in place from Topic 13).
|
||||
|
||||
The §15 surface depends on the producers being in place; with
|
||||
Slice 5 landing the last structural producer (graduation events,
|
||||
specifically `graduate_complete` as a personal-direct event for
|
||||
the proposer per §15.4), every signal a contributor needs to see
|
||||
is now in the audit log waiting to be fanned out.
|
||||
The carryovers Slice 7 inherits — the existing §14 spec text, the
|
||||
§17 endpoint set including Slice 6's settings endpoints, and the
|
||||
React Router layout already in place.
|
||||
|
||||
The next build session should read `SPEC.md`, `README.md`,
|
||||
`docs/DEV.md`, and `SPEC.md`'s §19.1 and pick up Slice 6 cleanly
|
||||
`docs/DEV.md`, and `SPEC.md`'s §19.1 and pick up Slice 7 cleanly
|
||||
without re-briefing. The working agreement in §19.3 continues to
|
||||
apply: implement the slice, correct the spec only where running
|
||||
code reveals it was wrong at a structural level, accumulate new
|
||||
|
||||
Reference in New Issue
Block a user