From f67d0aa0db789adbf60ae0e9bda3d59eeee33c9b Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 24 May 2026 23:09:04 -0700 Subject: [PATCH] =?UTF-8?q?Slice=206:=20notifications=20per=20=C2=A715?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 44 +- SPEC.md | 211 +++-- backend/app/api.py | 5 +- backend/app/api_branches.py | 29 + backend/app/api_graduation.py | 11 + backend/app/api_notifications.py | 421 ++++++++ backend/app/api_prs.py | 8 +- backend/app/bot.py | 14 +- backend/app/chat.py | 41 +- backend/app/digest.py | 275 ++++++ backend/app/email.py | 447 +++++++++ backend/app/main.py | 5 +- backend/app/notify.py | 948 +++++++++++++++++++ backend/migrations/008_email_opt_out.sql | 9 + backend/tests/test_notifications_vertical.py | 614 ++++++++++++ docs/DEV.md | 202 ++-- frontend/src/App.css | 79 ++ frontend/src/App.jsx | 54 +- frontend/src/api.js | 97 ++ frontend/src/components/Inbox.jsx | 188 ++++ frontend/src/components/ToastHost.jsx | 54 ++ 21 files changed, 3588 insertions(+), 168 deletions(-) create mode 100644 backend/app/api_notifications.py create mode 100644 backend/app/digest.py create mode 100644 backend/app/email.py create mode 100644 backend/app/notify.py create mode 100644 backend/migrations/008_email_opt_out.sql create mode 100644 backend/tests/test_notifications_vertical.py create mode 100644 frontend/src/components/Inbox.jsx create mode 100644 frontend/src/components/ToastHost.jsx diff --git a/README.md b/README.md index 23bdd49..a832f96 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,20 @@ Required values: | `OWNER_GITEA_LOGIN` | Your owner-zero Gitea login — gets the owner role on first sign-in. | | `GITEA_WEBHOOK_SECRET` | A shared secret for the §4.1 webhook signature. | -The LLM-provider settings (`ENABLED_MODELS`, `ANTHROPIC_API_KEY`, -etc.) are not exercised by Slice 1 but are wired through `config.py` -so the next slice can pick them up. +Optional values, picked up at process start: + +| Variable | What it is | +| -------------------------- | --------------------------------------------------------- | +| `ENABLED_MODELS` | Comma-separated provider keys for §18 chat (e.g. `claude,gemini`). | +| `ANTHROPIC_API_KEY` etc. | Per-provider keys; missing keys disable that provider. | +| `SMTP_HOST` / `SMTP_PORT` | §15.4 transactional-email adapter target. Empty falls back to logging the envelope to stdout — sufficient for dev and integration tests. | +| `SMTP_USER` / `SMTP_PASSWORD` | SMTP auth credentials. Optional alongside `SMTP_HOST`. | +| `SMTP_STARTTLS` | `1` (default) to negotiate STARTTLS; `0` for plaintext. | +| `EMAIL_FROM` | Envelope From address for §15.4 mail. Defaults to a non-routable placeholder. | +| `EMAIL_FROM_NAME` | Display name on the From header (default `Wiggleverse`). | +| `EMAIL_ENABLED` | `1` (default) to dispatch email; `0` to suppress all sends without disabling the inbox. | +| `EMAIL_BUNDLE_THRESHOLD` | Held-during-quiet-hours threshold for the "Activity while you were away" bundle (default 5, §15.4). | +| `DIGEST_TICK_SECONDS` | Cadence of the §15.5 digest scheduler's loop (default 3600). Tests drive ticks synchronously via `digest.run_tick`. | ### 6. Install dependencies @@ -150,7 +161,7 @@ button at the bottom opens the propose modal. ## What the build lets you do so far -Slices 1–5 are shipped. End-to-end paths the app supports today: +Slices 1–6 are shipped. End-to-end paths the app supports today: - **Propose → idea PR → merge → super-draft** (Slice 1, §9.1–§9.3). - **Super-draft body editing** via meta-repo edit branches, with AI @@ -172,6 +183,21 @@ Slices 1–5 are shipped. End-to-end paths the app supports today: §13 in full). - **§13.1 ownership claim** as a meta-repo PR adding the claimant to the entry's `owners:` field; admin/owner merges the PR (Slice 5). +- **§15 notifications** end-to-end: a producer-side chokepoint in + `notify.py` fans out from `actions` (and from chat-message + inserts that don't go through the bot) into `notifications` + rows under §15.1's routing rules; §15.6 watches auto-set on the + first substantive gesture and decay after 90 days; the header + badge and the `/inbox` overlay back the live counter via an SSE + stream per §15.3; toasts fire for personal-direct events and for + events landing on the view the user is currently watching; + §15.4 email opts in per category with one-click unsubscribe and + a global opt-out wired to the bounce webhook; §15.5 weekly / + daily digest assembles eligible churn into a single mail; §15.7 + reconciles unread state when a scope cursor advances; §15.8 + quiet hours hold email and digest while letting the inbox row + still land, and the per-user mute suppresses inbox rows + produced by a specific actor (Slice 6). This exercises the §4 cache (webhook + reconciler), the §6 permission model in full, the §1 bot wrapper (every Git write goes @@ -179,11 +205,11 @@ through it, every commit and PR carries the `On-behalf-of:` trailer), and the §17 routing-collapse rule that lets active and super-draft surfaces share their endpoints. -Out of scope for the slices shipped so far: notifications (Slice 6, -§15), landing-page and `/philosophy` chrome polish (Slice 7, §14), -the §12 30/90 branch-hygiene timers (Slice 8). The full slicing -plan and the next slice's brief live in -[`docs/DEV.md`](./docs/DEV.md). +Out of scope for the slices shipped so far: landing-page and +`/philosophy` chrome polish, the notification-settings UI surface, +and the admin neighbourhood (Slice 7, §14 + §19.2 candidates); the +§12 30/90 branch-hygiene timers (Slice 8). The full slicing plan +and the next slice's brief live in [`docs/DEV.md`](./docs/DEV.md). ## Verifying it worked diff --git a/SPEC.md b/SPEC.md index 9810577..1f3fcc3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2405,96 +2405,93 @@ surface. With Topic 13 folded in, the structural surface is complete. What follows is no longer "topics that block specifying v1" but "topics to address during or shortly after the v1 build." -### 19.1 Next slice: notifications per §15 +### 19.1 Next slice: the §14 chrome and the settings neighborhood -Slice 5 of the build has landed. The §13 graduation flow runs -end-to-end against the local Gitea — the Graduate dialog renders -the three editable fields (integer ID, repo name, initial owners) -with the debounced `GET /api/rfcs//graduate/check` lighting -up per-field validity inline, the precondition popover surfaces -open body-edit PRs via `GET /api/rfcs//blocking-prs` (the -§9.8 gate enforced before the sequence starts), and confirming the -dialog kicks off the §13.3 five-step sequence streamed via -`GET /api/rfcs//graduate/progress`. The orchestrator in -`api_graduation.py` runs the sequence as an asyncio task fed by an -in-memory queue; each step's bot primitive -(`create_rfc_repo_for_graduation`, `seed_graduated_rfc`, -`open_graduation_pr`, `merge_graduation_pr`) lands its own row in -`actions`, bracketed by `graduate_start` and `graduate_complete` -for the linkable sequence. Rollback is per-step and runs in -reverse: each forward step has a paired undo registered in -`_UNDO_BY_STEP` — the create-repo undo deletes the repo (which -also reclaims the seed commits, so seed-files' undo folds into -it), the open-pr undo closes the graduation PR. There is no -merge-pr undo by design; once the meta-repo merge has landed, -graduation is irreversible per §13.5. +Slice 6 of the build has landed. The §15 notifications surface runs +end-to-end against the local Gitea — every `actions` row whose +`action_kind` maps to a §15.1 event fans out through +`notify.fan_out_from_action`, called inline from `bot._log` and +from the graduation orchestrator's `_audit`. Chat-message inserts +take a parallel path through `notify.fan_out_chat_message` from +inside `chat.append_user_message`, since chat doesn't flow through +the bot wrapper. The §15.6 auto-watch upsert sits in the same +chokepoint — every substantive gesture either creates a `watching` +row or bumps `last_participation_at` for the 90-day decay timer. -§13.4's chat migration landed as a database semantic no-op — -the whole-doc main thread on the super-draft -(`rfc_slug=`, `branch_name='main'`) is the same row before -and after graduation; only the interpretation changes (canonical- -body view becomes per-RFC repo's main). The slug is the canonical -key per §2.3, so no data movement is needed. Edit-branch chats -stay attached to their original `branch_name` per §9.8's -no-data-movement framing; the §9.8 pre-graduation history -affordance on the new RFC view surfaces them as a distinct -disclosure in the breadcrumb dropdown, with the read path -dispatching against the meta repo via a new `_is_meta_target(rfc, -branch)` helper that handles both super-draft branches and active- -RFC pre-graduation meta-repo branches uniformly. +The §15.4 email loop runs through an SMTP adapter with a stdout +fallback for dev — the in-memory `_SENT` buffer is what the +integration tests read from. The per-category dispatch holds during +§15.8 quiet hours; on window-end, `email.flush_pending` bundles +above the §15.4 threshold into a single "Activity while you were +away" mail. The signed-URL unsubscribe path flips a single category +column to zero; the bounce webhook flips the new `email_opt_out_all` +column (migration `008_email_opt_out.sql`). -The §13.1 claim flow landed alongside graduation since it's the -prerequisite for non-admin graduation. The bot grew `open_claim_pr`; -`api_prs._require_pr` broadened to accept `pr_kind='meta_claim'` -so the merge surface inherits structurally from §10. Until §13.1's -claim runs, the dialog refuses the start when `owners=[]` and the -popover surfaces "Claim ownership yourself" as a remediation -affordance (admins are contributors per §6.1 and can claim solo). +The §15.5 digest is a `DigestScheduler` wrapping `cache.Reconciler`'s +shape, with a `run_tick` seam the tests drive synchronously. Each +tick releases held emails, runs the §15.6 90-day decay sweep, and +assembles per-cadence digests where the window has rolled over. +The §15.5 exclusion rules (already-emailed, already-read, +personal-direct-excluded) keep two consecutive ticks idempotent. -The five §17 routes Slice 5 added — `claim`, `blocking-prs`, -`graduate/check`, `graduate`, and `graduate/progress` — live in -`backend/app/api_graduation.py`. The §5 schema needed no migration. -On the frontend, `RFCView.jsx`'s breadcrumb actions grew -`Graduate to RFC repo` and `Claim ownership` buttons; -`GraduateDialog.jsx` owns the three-field surface, the precondition -popover, and the live step stack fed by an `EventSource` on the -progress SSE; the `BranchDropdown` gains a `Pre-graduation history` -disclosure that surfaces edit-branch threads on the new RFC view -per §9.8. +§15.2 / §15.3 / §15.7 / §15.8 surface as fourteen endpoints in +`backend/app/api_notifications.py`, plus the chat-seen advance on +`api_branches` and the existing PR seen-cursor on `api_prs` — both +extended to call `notify.reconcile_seen_advance` so the §15.7 +visit-advances-cursor loop closes back into the inbox-row read +state. The SSE stream holds a per-user subscriber queue keyed by +user_id; multiple browser tabs see the same events. -Slice 5 ships covered by `backend/tests/test_graduation_vertical.py` -— ten integration tests against the FakeGitea (extended with -`DELETE /repos/{owner}/{repo}` for the rollback inverse) covering -the dialog validator's per-field checks, the no-owners refusal, -the §9.8 precondition refusing the start, the §13.3 happy path -end-to-end with audit-log verification, mid-sequence rollback at -step 2 (seed) and step 3 (PR open), concurrent-graduation refusal, -§13.4's chat-row-survives contract, the §9.8 pre-graduation -history surface, and the §13.1 claim PR cycle. The full Slices 1–5 -test suite is 45/45 green. +On the frontend, `App.jsx` grew a header badge (cap "99+", +clicking opens the inbox overlay), an SSE-driven counter that +surfaces personal-direct toasts (own-name signals) and live-view +toasts (events landing on the slug the user is viewing). The +inbox is `Inbox.jsx` — three filter chips (Unread only, RFC, +Category), a Bundle toggle, and a "Mark all read (under filter)" +button. `ToastHost.jsx` caps four visible at once with auto-dismiss. -**Slice 6 is notifications per §15.** Every other vertical now -produces signals — propose, claim, merge, graduate, body edits, -manual flushes, PR open/withdraw/merge, review threads, conflict- -replay — and Slice 6 builds the surface that turns those signals -into a contributor's inbox. 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 §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. The producer-side hook is "after -a write succeeds, evaluate watches and fan-out notification rows" -— same chokepoint shape Slice 1's `_log` uses, invoked inline -from the bot wrapper. The consumer-side hook is the header badge, -the inbox panel, the toast surface, and the per-row read-state -machinery. The §15.4 email loop and the §15.5 digest are the -heavier sub-pieces — the digest needs a scheduled-job runner; -the email loop needs a transactional-email adapter and the -`POST /api/webhooks/email-bounce` receiver. +The §15.9 attribution rule fell out cleanly: every `notifications` +row carries `actor_user_id` resolved from the `actions.actor_user_id` +in the originating audit row (the underlying user, never the bot). +System-generated events (digest emission, 90-day decay) leave +`actor_user_id` NULL and render as "the app." AI participation +events landed as null-system per §19.2's candidate naming — when a +chat message authored by an AI provider goes through, no actor row +is written, since the LLM call doesn't have a user_id; the topic +folder for "AI participation as a notification source" in §19.2 +remains open for explicit settling. + +Slice 6 ships covered by `backend/tests/test_notifications_vertical.py` +— seventeen integration tests covering the producer-side fan-out +on the propose/merge/decline chain, §15.6 auto-watch, 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 flipping the global opt-out, the `/email/unsubscribe` +signed-URL path, the §15.8 quiet-hours email 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. + +**Slice 7 is the §14 chrome plus the natural notification-settings +neighbor.** With every structural beat live, what remains for v1 +is the chrome the framework wraps itself in. §14 commits the +landing page (the unauthenticated visitor's first read), the +`/philosophy` route (PHILOSOPHY.md surfaced inline), and the +persistent About link in the header. Slice 6 left the §15 +preferences / quiet-hours / mute / watches endpoints in place +but with no chrome — the natural follow-on is `/settings/notifications` +exposing the per-category toggles, the digest cadence dropdown, +the quiet-hours editor, the watches overview, and the per-user +mute list. The §19.2 "admin surfaces" candidate is the second +natural neighbor — role management, the §6.2 app-wide write-mute, +the audit-log viewer, the graduation-readiness queue, all +consolidated where the chrome can hold them. Slice 7 picks the +framing and ships the three pieces together since they share an +information architecture. The next build session should read `SPEC.md`, `README.md`, -`docs/DEV.md`, and this §19.1 entry and pick up Slice 6 cleanly +`docs/DEV.md`, and this §19.1 entry 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 @@ -2747,6 +2744,56 @@ binding. minimum that keeps the test surface terse without adding a separate test-only module. - **Body full-text search.** When the time comes. +- **The §15.2 inbox grouping's per-RFC + per-event-kind bundle's + represent-row choice.** Slice 6's bundle implementation collapses + rows under the (rfc_slug, event_kind) key and picks the most-recent + constituent as the representative. The §15.2 spec voice ("3 new + commits on PR #4 / RFC-0042" as a single bundle row) names the + count but not which representative's verb-phrase the bundle reads + as. A future session may settle whether the bundle reads in the + voice of the most-recent actor ("alice + 2 others added commits") + or a structural verb ("3 new commits on …"), and how the bundle + expands to its constituents (inline disclosure, modal, navigation). + Defer-able until usage shows the per-row shape doesn't suffice. +- **AI participation as a notification source — confirmed.** §19.2 + already named this as a candidate; Slice 6 didn't settle it. The + build chose null-system for AI-generated content for now (no + `actor_user_id` since the LLM call has no user row), but the + §15.9 framing of "the system did not invent attribution" reads + cleanly only for genuinely unattributed events (auto-close, + digest emission). An AI-authored chat reply produces a chat + message and could fire a chat_message_in_participated_thread + signal to other thread participants, with the actor reading as + "the AI participant" — a candidate distinct entity. Touches + §15.9 (the actor slot in inbox prose), §8.12 (the AI participant's + authored-message shape), and the §19.2 per-RFC model availability + topic (which AI participant is the right noun for a row coming + out of that RFC?). +- **Inbox row prose for null-actor events.** Slice 6 renders + null-actor rows with the literal noun "the app" per §15.9's + "absence of an actor is the honest signal" framing. The phrase + works for some events (the digest emission email body) but + reads awkwardly for others ("the app started a resolution + branch"). A future session may settle a per-event-kind null- + actor verb form so each row reads naturally without picking up + an apparent personification. Defer-able until contributor + feedback surfaces an irritating render. +- **Email bounce webhook authentication.** Slice 6's + `/api/webhooks/email-bounce` accepts unauthenticated POSTs for + v1 — the SMTP provider's callback URL is the contract. When an + actual provider is wired in, the webhook needs a shared secret + or signature verification (Sendgrid's signed events, AWS SES's + SNS topic signature, etc.). Trivial to add per provider; the + routing-and-flip-the-column logic doesn't change. +- **Per-user mute exemption checks for arbiters.** §15.8 commits + that arbiters cannot mute participants on RFCs where they hold + authority. Slice 6's check uses "the muted user has a watches + row on the same RFC where the muter is an arbiter" as the + participation proxy. The spec doesn't define "active" precisely + for this check; the watches-row proxy is generous (a user with a + read-only relationship counts as active). A future session may + settle a tighter definition (e.g., has any `actions` row on the + RFC) if the generous proxy refuses too many legitimate mutes. Topic 13 (notifications) is settled and folded into §5 (the notifications, watches, branch_chat_seen, notification_user_mutes, diff --git a/backend/app/api.py b/backend/app/api.py index db1f7e6..1e1b116 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -17,7 +17,7 @@ from typing import Any from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field -from . import api_branches, api_graduation, api_prs, auth, db, entry as entry_mod, cache +from . import api_branches, api_graduation, api_notifications, api_prs, auth, db, entry as entry_mod, cache from .bot import Bot from .config import Config from .gitea import Gitea, GiteaError @@ -55,6 +55,9 @@ def make_router( router.include_router(api_prs.make_router(config, gitea, bot, providers)) # Slice 5: §13 graduation + §13.1 claim. router.include_router(api_graduation.make_router(config, gitea, bot)) + # Slice 6: §15 notifications surface (inbox, watches, prefs, + # quiet hours, per-user mute, email unsubscribe, bounce webhook). + router.include_router(api_notifications.make_router(config)) # --------------------------------------------------------------- # Auth surface — extends the prototype's pattern but reads role diff --git a/backend/app/api_branches.py b/backend/app/api_branches.py index ba848d4..357da3f 100644 --- a/backend/app/api_branches.py +++ b/backend/app/api_branches.py @@ -901,6 +901,35 @@ def make_router( ) return {"ok": True, "message_id": message_id} + @router.post("/api/rfcs/{slug}/branches/{branch}/chat-seen") + async def advance_chat_seen(slug: str, branch: str, body: dict, request: Request) -> dict[str, Any]: + """§15.7 chat-seen cursor advance. + + Body: `{"last_seen_message_id": }`. Upserts branch_chat_seen + and runs the §15.7 reconciler — every unread notification scoped + to this (slug, branch) on or before the new cursor is marked read. + """ + viewer = auth.require_user(request) + _require_rfc_with_repo(slug) + if not _can_read_branch(slug, branch, viewer): + raise HTTPException(403, "Branch is private") + last_seen = int(body.get("last_seen_message_id") or 0) or None + db.conn().execute( + """ + INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(user_id, rfc_slug, branch_name) DO UPDATE SET + last_seen_message_id = excluded.last_seen_message_id, + seen_at = excluded.seen_at + """, + (viewer.user_id, slug, branch, last_seen), + ) + from . import notify + reconciled = notify.reconcile_seen_advance( + user_id=viewer.user_id, rfc_slug=slug, branch_name=branch, + ) + return {"ok": True, "reconciled": reconciled} + @router.post("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/resolve") async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) diff --git a/backend/app/api_graduation.py b/backend/app/api_graduation.py index 3ba43c3..d212a92 100644 --- a/backend/app/api_graduation.py +++ b/backend/app/api_graduation.py @@ -907,6 +907,17 @@ def _audit( json.dumps(details) if details else None, ), ) + # §15 chokepoint per Slice 6: the bracket rows (graduate_start, + # graduate_complete) drive their own notifications per §15.1. + from . import notify + notify.fan_out_from_action( + actor_user_id=actor_user_id, + action_kind=action_kind, + rfc_slug=rfc_slug, + branch_name=branch_name, + pr_number=pr_number, + details=details, + ) # --------------------------------------------------------------------------- diff --git a/backend/app/api_notifications.py b/backend/app/api_notifications.py new file mode 100644 index 0000000..cf19dc3 --- /dev/null +++ b/backend/app/api_notifications.py @@ -0,0 +1,421 @@ +"""§17 endpoints for the §15 notifications surface (Slice 6). + +The endpoints in this module are: + + - `GET /api/notifications` — §15.2 inbox listing + - `POST /api/notifications//read` — §15.2/§15.7 + - `POST /api/notifications/read` — §15.2 mark by filter + - `GET /api/notifications/stream` — §15.3 SSE + - `GET /api/watches` — §15.6 + - `POST /api/rfcs//watch` — §15.6 explicit set + - `GET /api/users/me/notification-preferences` — §15.4 / §15.5 + - `POST /api/users/me/notification-preferences` — set + - `GET /api/users/me/quiet-hours` — §15.8 + - `POST /api/users/me/quiet-hours` — set / clear + - `POST /api/users//notification-mute` — §15.8 + - `DELETE /api/users//notification-mute` — §15.8 + - `GET /api/email/unsubscribe` — §15.4 one-click + - `POST /api/webhooks/email-bounce` — §15.4 receiver + +The `branches//chat-seen` advance lives in `api_branches` next +to the other branch-scoped endpoints; it calls into `notify.reconcile_seen_advance` +per §15.7. The `prs//seen` endpoint in `api_prs` does the same. +""" +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any + +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, StreamingResponse +from itsdangerous import BadSignature +from pydantic import BaseModel, Field + +from . import auth, db, email as email_mod, notify +from .config import Config + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Pydantic bodies +# --------------------------------------------------------------------------- + + +class WatchBody(BaseModel): + state: str = Field(pattern="^(watching|following|muted)$") + + +class PreferencesBody(BaseModel): + email_personal_direct: bool | None = None + email_watched_structural: bool | None = None + email_admin_actionable: bool | None = None + digest_cadence: str | None = Field(default=None, pattern="^(off|weekly|daily)$") + + +class QuietHoursBody(BaseModel): + start: str | None = Field(default=None, pattern=r"^\d{2}:\d{2}$") + end: str | None = Field(default=None, pattern=r"^\d{2}:\d{2}$") + timezone: str | None = None + + +class MarkReadBody(BaseModel): + ids: list[int] | None = None + rfc_slug: str | None = None + category: str | None = None + actor_user_id: int | None = None + + +class BounceBody(BaseModel): + email: str = Field(min_length=3, max_length=320) + kind: str = Field(default="hard") # 'hard' or 'complaint' + + +# --------------------------------------------------------------------------- +# Router +# --------------------------------------------------------------------------- + + +def make_router(config: Config) -> APIRouter: + router = APIRouter() + + # ----- Inbox listing + mark-read ----- + + @router.get("/api/notifications") + async def list_notifications( + request: Request, + unread: bool = False, + rfc_slug: str | None = None, + category: str | None = None, + actor_user_id: int | None = None, + bundled: bool = False, + ) -> dict[str, Any]: + viewer = auth.require_user(request) + return notify.list_inbox( + user_id=viewer.user_id, + unread=unread, + rfc_slug=rfc_slug, + category=category, + actor_user_id=actor_user_id, + bundled=bundled, + ) + + @router.post("/api/notifications/{notif_id}/read") + async def mark_one_read(notif_id: int, request: Request) -> dict[str, Any]: + viewer = auth.require_user(request) + row = db.conn().execute( + "SELECT id FROM notifications WHERE id = ? AND recipient_user_id = ?", + (notif_id, viewer.user_id), + ).fetchone() + if row is None: + raise HTTPException(404, "Notification not found") + db.conn().execute( + "UPDATE notifications SET read_at = datetime('now') WHERE id = ? AND read_at IS NULL", + (notif_id,), + ) + # Push the read event so other tabs update their badge counts. + asyncio.create_task(notify._broadcast(viewer.user_id, "read", {"id": notif_id})) + return {"ok": True} + + @router.post("/api/notifications/read") + async def mark_filtered_read(body: MarkReadBody, request: Request) -> dict[str, Any]: + viewer = auth.require_user(request) + marked = notify.mark_read_by_filter( + user_id=viewer.user_id, + rfc_slug=body.rfc_slug, + category=body.category, + actor_user_id=body.actor_user_id, + ids=body.ids, + ) + return {"marked": marked} + + @router.get("/api/notifications/stream") + async def stream_notifications(request: Request): + viewer = auth.require_user(request) + sub = await notify.subscribe(viewer.user_id) + + async def event_stream(): + # On open, send the current unread count as a snapshot so + # the badge initializes correctly without a second request. + count = db.conn().execute( + "SELECT COUNT(*) AS c FROM notifications WHERE recipient_user_id = ? AND read_at IS NULL", + (viewer.user_id,), + ).fetchone()["c"] + yield _sse("snapshot", {"unread_count": count}) + try: + while True: + if await request.is_disconnected(): + break + try: + evt = await asyncio.wait_for(sub.queue.get(), timeout=15.0) + except asyncio.TimeoutError: + # Keep-alive comment line; clients ignore comments. + yield ": keep-alive\n\n" + continue + yield _sse(evt.get("event", "update"), evt.get("payload")) + finally: + await notify.unsubscribe(sub) + + headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"} + return StreamingResponse(event_stream(), media_type="text/event-stream", headers=headers) + + # ----- Watches ----- + + @router.get("/api/watches") + async def list_watches(request: Request) -> dict[str, Any]: + viewer = auth.require_user(request) + rows = db.conn().execute( + """ + SELECT w.id, w.rfc_slug, w.state, w.set_by, w.set_at, w.last_participation_at, + r.title AS rfc_title + FROM watches w + LEFT JOIN cached_rfcs r ON r.slug = w.rfc_slug + WHERE w.user_id = ? + ORDER BY w.set_at DESC + """, + (viewer.user_id,), + ).fetchall() + return { + "items": [ + { + "id": r["id"], + "rfc_slug": r["rfc_slug"], + "rfc_title": r["rfc_title"], + "state": r["state"], + "set_by": r["set_by"], + "set_at": r["set_at"], + "last_participation_at": r["last_participation_at"], + } + for r in rows + ] + } + + @router.post("/api/rfcs/{slug}/watch") + async def set_watch(slug: str, body: WatchBody, request: Request) -> dict[str, Any]: + viewer = auth.require_user(request) + rfc = db.conn().execute("SELECT slug FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() + if rfc is None: + raise HTTPException(404, "RFC not found") + db.conn().execute( + """ + INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at) + VALUES (?, ?, ?, 'explicit', datetime('now'), datetime('now')) + ON CONFLICT(user_id, rfc_slug) DO UPDATE SET + state = excluded.state, + set_by = 'explicit', + set_at = excluded.set_at + """, + (viewer.user_id, slug, body.state), + ) + return {"ok": True, "state": body.state, "set_by": "explicit"} + + # ----- Per-user notification preferences (§15.4 / §15.5) ----- + + @router.get("/api/users/me/notification-preferences") + async def get_prefs(request: Request) -> dict[str, Any]: + viewer = auth.require_user(request) + row = db.conn().execute( + """ + SELECT email_personal_direct, email_watched_structural, + email_admin_actionable, digest_cadence, email_opt_out_all + FROM users WHERE id = ? + """, + (viewer.user_id,), + ).fetchone() + return { + "email_personal_direct": bool(row["email_personal_direct"]), + "email_watched_structural": bool(row["email_watched_structural"]), + "email_admin_actionable": bool(row["email_admin_actionable"]), + "email_watched_churn": False, # §15.4 permanently off + "email_opt_out_all": bool(row["email_opt_out_all"]), + "digest_cadence": row["digest_cadence"], + } + + @router.post("/api/users/me/notification-preferences") + async def set_prefs(body: PreferencesBody, request: Request) -> dict[str, Any]: + viewer = auth.require_user(request) + sets = [] + args: list[Any] = [] + if body.email_personal_direct is not None: + sets.append("email_personal_direct = ?") + args.append(1 if body.email_personal_direct else 0) + if body.email_watched_structural is not None: + sets.append("email_watched_structural = ?") + args.append(1 if body.email_watched_structural else 0) + if body.email_admin_actionable is not None: + sets.append("email_admin_actionable = ?") + args.append(1 if body.email_admin_actionable else 0) + if body.digest_cadence is not None: + sets.append("digest_cadence = ?") + args.append(body.digest_cadence) + if not sets: + return {"ok": True} + args.append(viewer.user_id) + db.conn().execute(f"UPDATE users SET {', '.join(sets)} WHERE id = ?", args) + return {"ok": True} + + # ----- Quiet hours (§15.8) ----- + + @router.get("/api/users/me/quiet-hours") + async def get_quiet_hours(request: Request) -> dict[str, Any]: + viewer = auth.require_user(request) + row = db.conn().execute( + """ + SELECT notification_quiet_hours_start AS start, + notification_quiet_hours_end AS end_, + notification_quiet_hours_timezone AS tz + FROM users WHERE id = ? + """, + (viewer.user_id,), + ).fetchone() + return {"start": row["start"], "end": row["end_"], "timezone": row["tz"]} + + @router.post("/api/users/me/quiet-hours") + async def set_quiet_hours(body: QuietHoursBody, request: Request) -> dict[str, Any]: + viewer = auth.require_user(request) + # Per §15.8: all three to set, all null to clear. Reject partials. + filled = [body.start, body.end, body.timezone] + if any(filled) and not all(filled): + raise HTTPException(422, "Set start, end, and timezone together, or clear all three") + db.conn().execute( + """ + UPDATE users + SET notification_quiet_hours_start = ?, + notification_quiet_hours_end = ?, + notification_quiet_hours_timezone = ? + WHERE id = ? + """, + (body.start, body.end, body.timezone, viewer.user_id), + ) + return {"ok": True} + + # ----- Per-user notification mute (§15.8) ----- + + @router.post("/api/users/{user_id}/notification-mute") + async def add_user_mute(user_id: int, request: Request) -> dict[str, Any]: + viewer = auth.require_user(request) + if user_id == viewer.user_id: + raise HTTPException(422, "You cannot mute yourself") + # Refusal per §15.8: admins/owners cannot mute notifications + # from anyone (they are exercising app-wide authority); arbiters + # cannot mute participants on RFCs where they hold authority. + if viewer.role in ("owner", "admin"): + raise HTTPException( + 403, + "Admins and owners cannot mute notifications — the role requires receiving signals from everyone", + ) + if _is_arbiter_with_overlap(viewer.user_id, user_id): + raise HTTPException( + 403, + "You hold arbiter authority on an RFC where this user is active — muting is refused per §15.8", + ) + target = db.conn().execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone() + if target is None: + raise HTTPException(404, "User not found") + db.conn().execute( + """ + INSERT OR IGNORE INTO notification_user_mutes (muter_user_id, muted_user_id) + VALUES (?, ?) + """, + (viewer.user_id, user_id), + ) + return {"ok": True} + + @router.delete("/api/users/{user_id}/notification-mute") + async def remove_user_mute(user_id: int, request: Request) -> dict[str, Any]: + viewer = auth.require_user(request) + db.conn().execute( + "DELETE FROM notification_user_mutes WHERE muter_user_id = ? AND muted_user_id = ?", + (viewer.user_id, user_id), + ) + return {"ok": True} + + # ----- Email: one-click unsubscribe + bounce webhook ----- + + @router.get("/api/email/unsubscribe") + async def email_unsubscribe(t: str = Query(..., description="Signed token from the email footer")) -> HTMLResponse: + try: + user_id, category = email_mod.verify_unsubscribe_token(t) + except BadSignature: + return HTMLResponse( + "

Link expired or invalid

" + "

Open the app to manage your notification preferences directly.

", + status_code=400, + ) + column = { + "personal-direct": "email_personal_direct", + "structural": "email_watched_structural", + "admin-actionable": "email_admin_actionable", + }.get(category) + if column is None: + return HTMLResponse( + f"

Unknown category

{category}

", status_code=400 + ) + db.conn().execute(f"UPDATE users SET {column} = 0 WHERE id = ?", (user_id,)) + return HTMLResponse( + f"

Unsubscribed

You will no longer receive {category} emails. " + f"You can re-enable them in your notification preferences.

" + ) + + @router.post("/api/webhooks/email-bounce") + async def email_bounce(body: BounceBody) -> dict[str, Any]: + # §15.4: hard bounces and complaints flip the global opt-out. + # The webhook is unauthenticated here for v1 — the SMTP provider's + # callback URL is the contract. Tighten with a signing secret + # when an actual provider is wired in. + row = db.conn().execute( + "SELECT id FROM users WHERE LOWER(email) = LOWER(?)", (body.email,), + ).fetchone() + if row is None: + return {"ok": True, "matched": False} + db.conn().execute( + "UPDATE users SET email_opt_out_all = 1 WHERE id = ?", (row["id"],), + ) + log.info("email-bounce: opted out user %s (%s)", row["id"], body.kind) + return {"ok": True, "matched": True} + + return router + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _sse(event: str, payload: Any) -> str: + return f"event: {event}\ndata: {json.dumps(payload)}\n\n" + + +def _is_arbiter_with_overlap(muter_user_id: int, muted_user_id: int) -> bool: + """§15.8: an arbiter cannot mute notifications from a user who is + active on an RFC the arbiter has authority on. Active is defined + here as "has a watches row" — a low bar, but it's the cheapest + proxy for participation and the spec intends a generous refusal. + """ + muter_login_row = db.conn().execute( + "SELECT gitea_login FROM users WHERE id = ?", (muter_user_id,) + ).fetchone() + muted_login_row = db.conn().execute( + "SELECT gitea_login FROM users WHERE id = ?", (muted_user_id,) + ).fetchone() + if not muter_login_row or not muted_login_row: + return False + muter_login = muter_login_row["gitea_login"] + rfcs = db.conn().execute( + "SELECT slug, arbiters_json FROM cached_rfcs WHERE state = 'active'" + ).fetchall() + for r in rfcs: + try: + arbiters = json.loads(r["arbiters_json"] or "[]") + except json.JSONDecodeError: + continue + if muter_login in arbiters: + other_active = db.conn().execute( + "SELECT 1 FROM watches WHERE user_id = ? AND rfc_slug = ?", + (muted_user_id, r["slug"]), + ).fetchone() + if other_active: + return True + return False diff --git a/backend/app/api_prs.py b/backend/app/api_prs.py index e7f7dab..2dcbe9d 100644 --- a/backend/app/api_prs.py +++ b/backend/app/api_prs.py @@ -352,7 +352,13 @@ def make_router( """, (viewer.user_id, slug, pr_number, new_sha, new_msg), ) - return {"ok": True} + # §15.7 reconciler: a scope cursor advance marks unread + # notifications scoped to this PR read. + from . import notify + reconciled = notify.reconcile_seen_advance( + user_id=viewer.user_id, rfc_slug=slug, pr_number=pr_number, + ) + return {"ok": True, "reconciled": reconciled} # ------------------------------------------------------------------- # §10.4: post a review-kind thread anchored to a diff range. diff --git a/backend/app/bot.py b/backend/app/bot.py index c4f46f2..f66e1e5 100644 --- a/backend/app/bot.py +++ b/backend/app/bot.py @@ -25,7 +25,7 @@ from __future__ import annotations import json from dataclasses import dataclass -from . import db +from . import db, notify from .gitea import Gitea @@ -89,6 +89,18 @@ def _log( json.dumps(details) if details else None, ), ) + # §15 chokepoint per §19.1 brief: fan-out runs inline after the + # audit row lands. notify.py owns the routing rules and the + # auto-watch upsert per §15.6; the call is intentionally a + # single line here so the chokepoint is one place to read. + notify.fan_out_from_action( + actor_user_id=actor.user_id, + action_kind=action_kind, + rfc_slug=rfc_slug, + branch_name=branch_name, + pr_number=pr_number, + details=details, + ) class Bot: diff --git a/backend/app/chat.py b/backend/app/chat.py index 43c4b5e..3751668 100644 --- a/backend/app/chat.py +++ b/backend/app/chat.py @@ -139,7 +139,46 @@ def append_user_message( """, (thread_id, author_user_id, text, quote), ) - return cur.lastrowid + message_id = cur.lastrowid + # §15 chokepoint per Slice 6: chat messages don't flow through the + # bot wrapper (no Git write), so the fan-out is anchored here. The + # routing is: prior thread authors get personal-direct + # chat_reply_to_my_message; RFC watchers get churn-class + # chat_message_in_participated_thread. The notify module resolves + # the thread's RFC/branch context internally. + _fan_out_chat(thread_id, author_user_id, message_id) + return message_id + + +def _fan_out_chat(thread_id: int, author_user_id: int, message_id: int) -> None: + from . import notify + row = db.conn().execute( + "SELECT rfc_slug, branch_name, thread_kind FROM threads WHERE id = ?", + (thread_id,), + ).fetchone() + if row is None or not row["rfc_slug"]: + return + pr_number = None + if row["thread_kind"] == "review": + pr_row = db.conn().execute( + """ + SELECT pr_number FROM cached_prs + WHERE rfc_slug = ? AND head_branch = ? AND state = 'open' + ORDER BY pr_number DESC LIMIT 1 + """, + (row["rfc_slug"], row["branch_name"]), + ).fetchone() + if pr_row: + pr_number = pr_row["pr_number"] + notify.fan_out_chat_message( + actor_user_id=author_user_id, + rfc_slug=row["rfc_slug"], + branch_name=row["branch_name"] or "main", + thread_id=thread_id, + message_id=message_id, + is_review_thread=(row["thread_kind"] == "review"), + pr_number=pr_number, + ) def append_assistant_placeholder(*, thread_id: int, model_id: str) -> int: diff --git a/backend/app/digest.py b/backend/app/digest.py new file mode 100644 index 0000000..3af63b0 --- /dev/null +++ b/backend/app/digest.py @@ -0,0 +1,275 @@ +"""§15.5: the digest scheduler. + +A background asyncio task runs on a cadence (default hourly, configurable +via DIGEST_TICK_SECONDS for tests and dev). Each tick: + + - releases held-during-quiet-hours emails via `email.flush_pending`; + - decays §15.6 watching rows whose last_participation_at is >90 days; + - assembles a digest for every user whose `digest_cadence` is `daily` + or `weekly` and whose next-cadence window has rolled over. + +The digest's three exclusion rules per §15.5 are applied at assembly +time, and `notification_digests` records what was included so the next +run skips already-shipped rows. + +Production runs the loop continuously; tests drive it via `run_tick()` +for deterministic post-conditions (same shape as Slice 5's `?_sync=1` +seam for the graduation orchestrator). +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +from datetime import datetime, timedelta, timezone + +from . import db, email as email_mod, notify + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Scheduler shell +# --------------------------------------------------------------------------- + + +class DigestScheduler: + """Periodic task wrapper. Mirrors `cache.Reconciler`'s shape so the + operator's mental model is "this app has two scheduled jobs, both + look the same.\"""" + + def __init__(self, *, tick_seconds: int | None = None): + self._tick = tick_seconds or int(os.environ.get("DIGEST_TICK_SECONDS", "3600")) + self._task: asyncio.Task | None = None + self._stop = asyncio.Event() + + def start(self) -> None: + if self._task is None: + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + self._stop.set() + if self._task is not None: + await self._task + + async def _loop(self) -> None: + # One tick at startup so a fresh process serves digests immediately + # for any user whose cadence rolled over while the app was down. + await self._safe_tick() + while not self._stop.is_set(): + try: + await asyncio.wait_for(self._stop.wait(), timeout=self._tick) + except asyncio.TimeoutError: + pass + if self._stop.is_set(): + break + await self._safe_tick() + + async def _safe_tick(self) -> None: + try: + run_tick() + except Exception: + log.exception("digest tick failed") + + +# --------------------------------------------------------------------------- +# The tick itself +# --------------------------------------------------------------------------- + + +def run_tick() -> dict[str, int]: + """One pass: flush held emails, decay watches, assemble due digests. + + Returns counters for testing/observability. Idempotent on re-entry + — assemble_for_user respects the §15.5 exclusion rules so a second + tick during the same cadence window emits nothing.""" + flushed = email_mod.flush_pending() + decayed = notify.decay_watches() + digests_sent = 0 + rows = db.conn().execute( + """ + SELECT id, email, display_name, digest_cadence + FROM users + WHERE digest_cadence IN ('daily', 'weekly') + AND email IS NOT NULL AND email != '' + """ + ).fetchall() + for row in rows: + if assemble_for_user( + user_id=row["id"], + cadence=row["digest_cadence"], + email=row["email"], + display_name=row["display_name"], + ): + digests_sent += 1 + return {"flushed": flushed, "decayed": decayed, "digests_sent": digests_sent} + + +def assemble_for_user( + *, + user_id: int, + cadence: str, + email: str, + display_name: str, +) -> bool: + """§15.5 digest assembly for one user. + + Returns True if a digest was sent, False if skipped (nothing to + report, or cadence window not yet rolled over).""" + cfg = email_mod.EmailConfig.from_env() + if not cfg.enabled: + return False + now = datetime.now(timezone.utc) + last_row = db.conn().execute( + "SELECT sent_at, period_end FROM notification_digests WHERE recipient_user_id = ? ORDER BY id DESC LIMIT 1", + (user_id,), + ).fetchone() + period_start = ( + _parse_iso(last_row["period_end"]) if last_row and last_row["period_end"] else None + ) + if period_start is None: + # First digest: cover everything we have. Cap the lookback at + # 30 days so a fresh subscription doesn't dump months of history. + period_start = now - timedelta(days=30) + if not _cadence_window_rolled_over(period_start, now, cadence): + return False + + rows = db.conn().execute( + """ + SELECT n.id, n.event_kind, n.rfc_slug, n.branch_name, n.pr_number, + n.created_at, n.payload, n.read_at, n.email_sent_at, + u.display_name AS actor_display, + r.title AS rfc_title + FROM notifications n + LEFT JOIN users u ON u.id = n.actor_user_id + LEFT JOIN cached_rfcs r ON r.slug = n.rfc_slug + WHERE n.recipient_user_id = ? + AND n.created_at >= ? + ORDER BY n.id ASC + """, + (user_id, period_start.strftime("%Y-%m-%d %H:%M:%S")), + ).fetchall() + + eligible: list[tuple] = [] + for r in rows: + # §15.5 exclusion rule 1: already emailed. + if r["email_sent_at"]: + continue + # §15.5 exclusion rule 2: already read. + if r["read_at"]: + continue + try: + extras = json.loads(r["payload"] or "{}") + except json.JSONDecodeError: + extras = {} + # The §15.5 framing is "the catch-up surface for activity on + # watched RFCs." Personal-direct events have their own email + # path; exclude them from the digest body per the section's + # closing paragraph. + if extras.get("category") == "personal-direct": + continue + eligible.append((r, extras)) + + if not eligible: + # No digest is sent when there's nothing to report (§15.5), + # but we still record the period roll so the next window + # starts cleanly. + _record_emitted(user_id, period_start, now, ids=[]) + return False + + subject = _subject(eligible, cadence) + body = _body(eligible, cadence, cfg) + sent = email_mod._deliver(cfg, email, subject, body) + if not sent: + return False + ids = [r["id"] for r, _ in eligible] + placeholders = ",".join("?" * len(ids)) + db.conn().execute( + f"UPDATE notifications SET digest_included_at = datetime('now') WHERE id IN ({placeholders})", + ids, + ) + _record_emitted(user_id, period_start, now, ids=ids) + return True + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _cadence_window_rolled_over(period_start: datetime, now: datetime, cadence: str) -> bool: + delta = now - period_start + if cadence == "daily": + return delta >= timedelta(days=1) + if cadence == "weekly": + return delta >= timedelta(days=7) + return False + + +def _parse_iso(text: str) -> datetime | None: + # SQLite stores datetimes as "YYYY-MM-DD HH:MM:SS" via datetime('now'). + try: + dt = datetime.strptime(text, "%Y-%m-%d %H:%M:%S") + return dt.replace(tzinfo=timezone.utc) + except (ValueError, TypeError): + return None + + +def _subject(eligible: list[tuple], cadence: str) -> str: + rfcs = {r["rfc_slug"] for r, _ in eligible if r["rfc_slug"]} + label = "Daily" if cadence == "daily" else "Weekly" + return f"[Wiggleverse] {label} digest — {len(eligible)} events across {len(rfcs)} RFCs" + + +def _body(eligible: list[tuple], cadence: str, cfg) -> str: + label = "the past day" if cadence == "daily" else "the past week" + lines = [f"Activity on RFCs you watch, from {label}.\n"] + grouped: dict[str | None, list[tuple]] = {} + for r, extras in eligible: + grouped.setdefault(r["rfc_slug"], []).append((r, extras)) + by_volume = sorted(grouped.items(), key=lambda kv: -len(kv[1])) + for slug, items in by_volume: + title = items[0][0]["rfc_title"] or slug or "(no RFC)" + lines.append(f"\n{title}") + if slug: + lines.append(f" {cfg.app_url}/rfc/{slug}") + # Group by event_kind within the RFC per §15.5's per-RFC + # section shape ("3 PRs opened on RFC-0042 …"). + by_kind: dict[str, list[tuple]] = {} + for r, extras in items: + by_kind.setdefault(r["event_kind"], []).append((r, extras)) + for event_kind, kind_items in by_kind.items(): + if len(kind_items) == 1: + r, extras = kind_items[0] + summary = notify.render_summary( + event_kind, r["actor_display"], r["rfc_title"], extras + ) + line = f" · {summary}" + else: + line = f" · {len(kind_items)} {event_kind.replace('_', ' ')} events" + # §15.5 exclusion rule 3: annotate still-unread items as + # "still unread in your inbox." + if any(rr["read_at"] is None for rr, _ in kind_items): + line += " (still unread in your inbox)" + lines.append(line) + lines.append(f"\nOpen your inbox: {cfg.app_url}/inbox") + lines.append(f"Manage digest preferences: {cfg.app_url}/settings/notifications") + return "\n".join(lines) + "\n" + + +def _record_emitted(user_id: int, period_start: datetime, now: datetime, *, ids: list[int]) -> None: + db.conn().execute( + """ + INSERT INTO notification_digests + (recipient_user_id, sent_at, period_start, period_end, signal_ids_included) + VALUES (?, ?, ?, ?, ?) + """, + ( + user_id, + now.strftime("%Y-%m-%d %H:%M:%S"), + period_start.strftime("%Y-%m-%d %H:%M:%S"), + now.strftime("%Y-%m-%d %H:%M:%S"), + json.dumps(ids), + ), + ) diff --git a/backend/app/email.py b/backend/app/email.py new file mode 100644 index 0000000..8e28974 --- /dev/null +++ b/backend/app/email.py @@ -0,0 +1,447 @@ +"""§15.4: the email loop. + +The transactional-email adapter is a thin wrapper over SMTP. When SMTP +credentials aren't configured the adapter falls back to logging the +envelope to stdout — sufficient for dev and for the integration tests, +which assert on the log buffer rather than spinning up a mail server. + +Per §15.4: opt-in per category (with category-specific defaults), one- +click unsubscribe per category via signed URL, single non-spoofing From +identity, body mirrors the inbox row verbatim. Bounces and complaints +route to a global opt-out per the same section. + +Quiet hours per §15.8 hold the send (the notification row still lands; +the email defers) until the window ends. The release-from-hold pass is +the same `flush_pending` the digest job calls; it bundles into a single +"Activity while you were away" email when more than a threshold +accumulated, otherwise sending individually. +""" +from __future__ import annotations + +import json +import logging +import os +import smtplib +from dataclasses import dataclass +from datetime import datetime, time, timezone +from email.message import EmailMessage +from email.utils import formataddr +from itertools import groupby +from typing import Any +from urllib.parse import urlencode + +from itsdangerous import BadSignature, URLSafeSerializer + +from . import db + +log = logging.getLogger(__name__) + + +# Buffer of outbound envelopes for test inspection. The integration tests +# read from this rather than spinning up a real SMTP server. Production +# leaves it as an unbounded list (it's never read), which is fine at +# v1 volumes; if it ever becomes a memory issue, we cap it then. +_SENT: list[dict] = [] + + +def sent_envelopes() -> list[dict]: + return list(_SENT) + + +def reset_sent_envelopes() -> None: + _SENT.clear() + + +# --------------------------------------------------------------------------- +# Configuration — read from env at call time so tests can monkeypatch +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class EmailConfig: + smtp_host: str + smtp_port: int + smtp_user: str + smtp_password: str + smtp_starttls: bool + from_address: str + from_name: str + app_url: str + bundle_threshold: int + enabled: bool + + @classmethod + def from_env(cls) -> "EmailConfig": + host = os.environ.get("SMTP_HOST", "").strip() + return cls( + smtp_host=host, + smtp_port=int(os.environ.get("SMTP_PORT", "587")), + smtp_user=os.environ.get("SMTP_USER", "").strip(), + smtp_password=os.environ.get("SMTP_PASSWORD", ""), + smtp_starttls=os.environ.get("SMTP_STARTTLS", "1") not in ("0", "false", "False", ""), + from_address=os.environ.get("EMAIL_FROM", "notifications@wiggleverse.local").strip(), + from_name=os.environ.get("EMAIL_FROM_NAME", "Wiggleverse").strip(), + app_url=os.environ.get("APP_URL", "http://localhost:8000").rstrip("/"), + bundle_threshold=int(os.environ.get("EMAIL_BUNDLE_THRESHOLD", "5")), + enabled=os.environ.get("EMAIL_ENABLED", "1") not in ("0", "false", "False"), + ) + + +# Signed-URL token for §15.4 one-click unsubscribe. The signer is +# scoped to (user_id, category) so the URL is idempotent and revocable +# by rotating SECRET_KEY. +def _signer() -> URLSafeSerializer: + secret = os.environ.get("SECRET_KEY", "") + if not secret: + raise RuntimeError("SECRET_KEY required for email signing") + return URLSafeSerializer(secret, salt="email-unsubscribe") + + +def make_unsubscribe_url(user_id: int, category: str) -> str: + cfg = EmailConfig.from_env() + token = _signer().dumps({"u": user_id, "c": category}) + qs = urlencode({"t": token}) + return f"{cfg.app_url}/api/email/unsubscribe?{qs}" + + +def verify_unsubscribe_token(token: str) -> tuple[int, str]: + """Returns (user_id, category) or raises BadSignature.""" + data = _signer().loads(token) + return int(data["u"]), str(data["c"]) + + +# --------------------------------------------------------------------------- +# Category routing +# --------------------------------------------------------------------------- + + +# Map §15.1 event_kinds to one of the four §15.4 categories. The 'churn' +# category never emails per §15.4 — naming the refusal in settings is +# more honest than silently omitting the toggle. +_EVENT_TO_CATEGORY: dict[str, str] = { + "proposal_merged": "personal-direct", + "proposal_declined": "personal-direct", + "proposal_opened_on_watched_topic": "structural", + "pr_opened": "structural", + "pr_merged": "structural", + "pr_withdrawn": "structural", + "pr_commit_added": "churn", + "pr_review_thread_new": "structural", + "pr_review_thread_reply": "personal-direct", + "pr_conflict_with_main": "structural", + "chat_message_in_participated_thread": "churn", + "chat_reply_to_my_message": "personal-direct", + "change_proposed_on_edited_passage": "personal-direct", + "flag_dropped_on_watched_rfc": "structural", + "flag_resolved_on_my_flag": "personal-direct", + "contribute_grant_added": "personal-direct", + "contribute_grant_revoked": "personal-direct", + "graduation_complete": "personal-direct", + "super_draft_graduation_ready": "admin-actionable", + "claim_opened": "structural", +} + + +def category_for(event_kind: str, fallback_category: str) -> str: + return _EVENT_TO_CATEGORY.get(event_kind, fallback_category) + + +def _user_wants_email(user_row: Any, category: str) -> bool: + """Apply the §15.4 per-category toggle. Churn always returns False + (the toggle is permanently off per the spec). Global opt-out + (`email_opt_out_all`) suppresses every category.""" + if user_row["email_opt_out_all"]: + return False + if category == "churn": + return False + if category == "personal-direct": + return bool(user_row["email_personal_direct"]) + if category == "structural": + return bool(user_row["email_watched_structural"]) + if category == "admin-actionable": + if user_row["role"] not in ("owner", "admin"): + return False + return bool(user_row["email_admin_actionable"]) + return False + + +# --------------------------------------------------------------------------- +# Quiet hours (§15.8) +# --------------------------------------------------------------------------- + + +def _in_quiet_hours(user_row: Any) -> bool: + start = user_row["notification_quiet_hours_start"] + end = user_row["notification_quiet_hours_end"] + tz_name = user_row["notification_quiet_hours_timezone"] + if not (start and end and tz_name): + return False + try: + from zoneinfo import ZoneInfo + tz = ZoneInfo(tz_name) + except Exception: + return False + now_local = datetime.now(tz).time() + start_t = _parse_hhmm(start) + end_t = _parse_hhmm(end) + if start_t is None or end_t is None: + return False + if start_t <= end_t: + return start_t <= now_local < end_t + # Wraps midnight (e.g. 22:00 → 07:00). + return now_local >= start_t or now_local < end_t + + +def _parse_hhmm(text: str) -> time | None: + try: + hh, mm = text.split(":", 1) + return time(int(hh), int(mm)) + except (ValueError, AttributeError): + return None + + +# --------------------------------------------------------------------------- +# The dispatch entry point — called from notify._schedule_email +# --------------------------------------------------------------------------- + + +def maybe_send( + *, + notif_id: int, + recipient_user_id: int, + event_kind: str, + category: str, + payload: dict, +) -> None: + """Apply §15.4's per-category opt-in and §15.8's quiet-hours hold, + then send. The notification row still landed in `notifications` + regardless; this only governs the out-of-band reach.""" + user = db.conn().execute( + """ + SELECT id, email, display_name, role, + email_personal_direct, email_watched_structural, email_admin_actionable, + email_opt_out_all, + notification_quiet_hours_start, notification_quiet_hours_end, + notification_quiet_hours_timezone + FROM users WHERE id = ? + """, + (recipient_user_id,), + ).fetchone() + if user is None or not user["email"]: + return + effective_category = category_for(event_kind, category) + if not _user_wants_email(user, effective_category): + return + if _in_quiet_hours(user): + # §15.8: hold during the window. The row's email_sent_at stays + # null; the digest's exclusion rule 1 won't kick in yet. The + # `flush_pending` pass at window end picks it up. + return + _send_one(user, notif_id, payload, effective_category) + + +def _send_one(user: Any, notif_id: int, payload: dict, category: str) -> None: + cfg = EmailConfig.from_env() + if not cfg.enabled: + return + subject = _subject(payload) + body = _body(payload, user["id"], category, cfg) + sent = _deliver(cfg, user["email"], subject, body) + if not sent: + return + db.conn().execute( + "UPDATE notifications SET email_sent_at = datetime('now') WHERE id = ?", + (notif_id,), + ) + + +def _subject(payload: dict) -> str: + rfc_title = payload.get("rfc_title") or payload.get("rfc_slug") or "" + summary = payload.get("summary") or payload.get("event_kind") or "" + if rfc_title: + return f"[Wiggleverse] {summary} — {rfc_title}".strip() + return f"[Wiggleverse] {summary}".strip() + + +def _body(payload: dict, user_id: int, category: str, cfg: EmailConfig) -> str: + summary = payload.get("summary") or "" + actor = payload.get("actor_display") or "the app" + rfc_title = payload.get("rfc_title") or payload.get("rfc_slug") or "this RFC" + when = payload.get("created_at") or "" + link = _deep_link(payload, cfg) + unsub = make_unsubscribe_url(user_id, category) + manage = f"{cfg.app_url}/settings/notifications" + return ( + f"{summary}\n\n" + f"by {actor} on {rfc_title} · {when}\n\n" + f"{link}\n\n" + f"---\n" + f"Unsubscribe from this category: {unsub}\n" + f"Manage all preferences: {manage}\n" + ) + + +def _deep_link(payload: dict, cfg: EmailConfig) -> str: + slug = payload.get("rfc_slug") + pr = payload.get("pr_number") + branch = payload.get("branch_name") + if slug and pr: + return f"{cfg.app_url}/rfc/{slug}/pr/{pr}" + if slug and branch: + return f"{cfg.app_url}/rfc/{slug}?branch={branch}" + if slug: + return f"{cfg.app_url}/rfc/{slug}" + return cfg.app_url + + +def _deliver(cfg: EmailConfig, to_address: str, subject: str, body: str) -> bool: + envelope = { + "to": to_address, + "from": formataddr((cfg.from_name, cfg.from_address)), + "subject": subject, + "body": body, + } + _SENT.append(envelope) + if not cfg.smtp_host: + log.info("email (stdout fallback): to=%s subject=%s", to_address, subject) + return True + try: + msg = EmailMessage() + msg["From"] = envelope["from"] + msg["To"] = to_address + msg["Subject"] = subject + msg.set_content(body) + smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) + try: + if cfg.smtp_starttls: + smtp.starttls() + if cfg.smtp_user: + smtp.login(cfg.smtp_user, cfg.smtp_password) + smtp.send_message(msg) + finally: + smtp.quit() + return True + except Exception: + log.exception("email send failed: to=%s subject=%s", to_address, subject) + return False + + +# --------------------------------------------------------------------------- +# Quiet-hours release pass — called from the digest job +# --------------------------------------------------------------------------- + + +def flush_pending() -> int: + """§15.8 release-from-hold. For each user whose quiet-hours window + has ended (or who has no quiet hours configured), find notifications + whose `email_sent_at IS NULL` and whose category is enabled, and + send them. Bundle into a single "Activity while you were away" + when more than the threshold accumulated.""" + cfg = EmailConfig.from_env() + if not cfg.enabled: + return 0 + + users = db.conn().execute( + """ + SELECT id, email, display_name, role, + email_personal_direct, email_watched_structural, email_admin_actionable, + email_opt_out_all, + notification_quiet_hours_start, notification_quiet_hours_end, + notification_quiet_hours_timezone + FROM users + """ + ).fetchall() + sent_count = 0 + for user in users: + if not user["email"]: + continue + if _in_quiet_hours(user): + continue + rows = db.conn().execute( + """ + SELECT n.id, n.event_kind, n.rfc_slug, n.branch_name, n.pr_number, + n.created_at, n.payload, + u.display_name AS actor_display, + r.title AS rfc_title + FROM notifications n + LEFT JOIN users u ON u.id = n.actor_user_id + LEFT JOIN cached_rfcs r ON r.slug = n.rfc_slug + WHERE n.recipient_user_id = ? + AND n.email_sent_at IS NULL + AND n.read_at IS NULL + ORDER BY n.id ASC + """, + (user["id"],), + ).fetchall() + emailable = [] + for r in rows: + try: + extras = json.loads(r["payload"] or "{}") + except json.JSONDecodeError: + extras = {} + cat = category_for(r["event_kind"], extras.get("category", "structural")) + if not _user_wants_email(user, cat): + continue + emailable.append((r, cat, extras)) + if not emailable: + continue + if len(emailable) >= cfg.bundle_threshold: + sent_count += _send_bundle(cfg, user, emailable) + else: + for r, cat, extras in emailable: + payload = _row_to_payload(r, extras) + _send_one(user, r["id"], payload, cat) + sent_count += 1 + return sent_count + + +def _row_to_payload(row: Any, extras: dict) -> dict: + return { + "event_kind": row["event_kind"], + "rfc_slug": row["rfc_slug"], + "rfc_title": row["rfc_title"], + "branch_name": row["branch_name"], + "pr_number": row["pr_number"], + "actor_display": row["actor_display"], + "created_at": row["created_at"], + "summary": _summary_for(row["event_kind"], row["actor_display"], row["rfc_title"], extras), + **extras, + } + + +def _summary_for(event_kind: str, actor_display: str | None, rfc_title: str | None, extras: dict) -> str: + # Local copy to avoid import cycle with notify.py. + from .notify import render_summary + return render_summary(event_kind, actor_display, rfc_title, extras) + + +def _send_bundle(cfg: EmailConfig, user: Any, emailable: list) -> int: + """One "Activity while you were away" email per §15.4. Subject names + the count, body lists summaries grouped by RFC.""" + count = len(emailable) + subject = f"[Wiggleverse] Activity while you were away — {count} events" + sections: list[str] = [] + sorted_rows = sorted(emailable, key=lambda t: t[0]["rfc_slug"] or "") + for slug, group in groupby(sorted_rows, key=lambda t: t[0]["rfc_slug"]): + group_rows = list(group) + title = group_rows[0][0]["rfc_title"] or slug or "(no RFC)" + sections.append(f"\n{title}") + for r, _cat, extras in group_rows: + summary = _summary_for(r["event_kind"], r["actor_display"], r["rfc_title"], extras) + sections.append(f" · {summary}") + body = ( + "Activity on RFCs you watch, accumulated during your quiet hours:\n" + + "\n".join(sections) + + f"\n\nOpen your inbox: {cfg.app_url}/inbox\n" + + f"Manage all preferences: {cfg.app_url}/settings/notifications\n" + ) + sent = _deliver(cfg, user["email"], subject, body) + if not sent: + return 0 + ids = [r["id"] for r, _, _ in emailable] + placeholders = ",".join("?" * len(ids)) + db.conn().execute( + f"UPDATE notifications SET email_sent_at = datetime('now') WHERE id IN ({placeholders})", + ids, + ) + return count diff --git a/backend/app/main.py b/backend/app/main.py index 01acce4..4d4c05c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, FastAPI, HTTPException, Request from fastapi.responses import RedirectResponse from starlette.middleware.sessions import SessionMiddleware -from . import api as api_routes, auth, cache, db, providers as providers_mod, webhooks +from . import api as api_routes, auth, cache, db, digest, providers as providers_mod, webhooks from .bot import Bot from .config import load_config from .gitea import Gitea @@ -31,6 +31,7 @@ async def lifespan(app: FastAPI): gitea = Gitea(config) bot = Bot(gitea) reconciler = cache.Reconciler(config, gitea) + digest_sched = digest.DigestScheduler() # §18 carryover: the multi-provider LLM abstraction. Provider # construction can fail (missing key, wrong env value) — if it does, @@ -53,10 +54,12 @@ async def lifespan(app: FastAPI): app.include_router(webhooks.make_router(config, gitea)) reconciler.start() + digest_sched.start() log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo) try: yield finally: + await digest_sched.stop() await reconciler.stop() await gitea.close() diff --git a/backend/app/notify.py b/backend/app/notify.py new file mode 100644 index 0000000..610195d --- /dev/null +++ b/backend/app/notify.py @@ -0,0 +1,948 @@ +"""§15: the notifications fan-out and the SSE broadcaster. + +This module is the single chokepoint for the producer side of §15. +Every write that names an `rfc_slug` flows through `fan_out_from_action` +(called from `bot._log`) or `fan_out_chat_message` (called from the chat +write paths in `api_branches` / `api_prs`, since chat messages don't go +through the bot wrapper). The chokepoint: + + - upserts a `watches` row for the actor per §15.6's auto-watch rule + (a substantive gesture sets `watching`, never downgrades, and bumps + `last_participation_at` for the 90-day decay); + - applies the §15.1 routing rules to determine which other users + receive a notification for the event; + - filters out muted recipients per §15.8 (per-user mute list); + - filters out `watches.state='muted'` per §15.6 (per-RFC mute); + - inserts one `notifications` row per recipient with the underlying + actor's user id per §15.9 (the bot never appears as actor); + - dispatches the row to the §15.4 email path if the recipient's + category toggle is on (held during quiet hours per §15.8, deferred + to the digest otherwise); + - publishes each new row to the per-user SSE subscriber queue so the + inbox refresh and header-badge count back the same stream per §15.3. + +If you find yourself wanting to insert a row into `notifications` +directly from an endpoint, the spec is right and you are wrong: the +chokepoint is the invariant. Read paths can query the table from +anywhere; the producer side flows through here. +""" +from __future__ import annotations + +import asyncio +import json +import logging +from dataclasses import dataclass, field +from typing import Any, Iterable + +from . import db + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# §15.1 routing — action_kind → (event_kind, category, recipient_set) +# --------------------------------------------------------------------------- +# +# Three recipient sets are used by the routing rules below: +# +# "watchers" — every user with a watches row on this slug whose state +# is 'watching' (full stream) or 'following' (structural +# only), minus the actor and minus 'muted' rows. The +# structural/full split is applied per-event below. +# "owners" — every user named in the entry's owners_json frontmatter +# on cached_rfcs (resolved to user_ids via gitea_login). +# "proposer" — the user whose action_kind=propose_rfc opened the slug, +# resolved via the most recent matching audit row. +# +# The category of each event maps to one of the §15.4 buckets: +# +# "personal-direct" — recipient is named subject of the action. +# "structural" — structural beats on watched RFCs. +# "churn" — per-commit / per-message activity. Never emails +# per §15.4; aggregated by the §15.5 digest. + +CATEGORY_PERSONAL = "personal-direct" +CATEGORY_STRUCTURAL = "structural" +CATEGORY_CHURN = "churn" + +# Action kinds whose actor's first interaction with a slug triggers +# auto-watch per §15.6. The substantive-gesture list in the spec is +# the source; we map onto our action_kind taxonomy. Reads, view advances, +# and own-action observations are intentionally excluded. +_AUTO_WATCH_ACTIONS = { + "propose_rfc", + "merge_proposal", + "decline_proposal", + "create_branch", + "accept_change", + "decline_change", + "manual_flush", + "open_branch_pr", + "merge_branch_pr", + "withdraw_branch_pr", + "supersede_branch_pr", + "open_metadata_pr", + "open_claim_pr", + "merge_claim_pr", + "create_resolution_branch", + "replay_change", + "graduate_start", + "graduate_complete", + "open_review_thread", + "resolve_thread", + "drop_flag", + "resolve_flag", +} + + +# --------------------------------------------------------------------------- +# SSE broadcaster — per-user subscriber registry +# --------------------------------------------------------------------------- + + +@dataclass +class _Subscriber: + user_id: int + queue: asyncio.Queue = field(default_factory=asyncio.Queue) + + +_subscribers: dict[int, list[_Subscriber]] = {} +_subscribers_lock: asyncio.Lock | None = None + + +def _get_lock() -> asyncio.Lock: + global _subscribers_lock + if _subscribers_lock is None: + _subscribers_lock = asyncio.Lock() + return _subscribers_lock + + +async def subscribe(user_id: int) -> _Subscriber: + """Register a new SSE subscriber for a user. Each browser tab gets its + own subscriber; the per-user fan-out pushes the same event to every + subscriber for that user per §15 (multiple tabs see the same updates).""" + sub = _Subscriber(user_id=user_id) + async with _get_lock(): + _subscribers.setdefault(user_id, []).append(sub) + return sub + + +async def unsubscribe(sub: _Subscriber) -> None: + async with _get_lock(): + bucket = _subscribers.get(sub.user_id, []) + if sub in bucket: + bucket.remove(sub) + if not bucket: + _subscribers.pop(sub.user_id, None) + + +async def _broadcast(user_id: int, event: str, payload: Any) -> None: + """Push an event to every subscriber for one user. + + Best-effort; if the queue is unbounded a slow consumer just sees the + backlog when it next drains. We deliberately do not block writers on + consumer presence — the durable inbox row is the source of truth.""" + async with _get_lock(): + subs = list(_subscribers.get(user_id, [])) + for sub in subs: + try: + sub.queue.put_nowait({"event": event, "payload": payload}) + except Exception: + log.exception("notify broadcast: drop event for user %s", user_id) + + +# --------------------------------------------------------------------------- +# Producer-side entry points +# --------------------------------------------------------------------------- + + +def fan_out_from_action( + *, + actor_user_id: int | None, + action_kind: str, + rfc_slug: str | None, + branch_name: str | None, + pr_number: int | None, + details: dict | None, +) -> None: + """Called from `bot._log` after every successful Git write. + + Synchronous on purpose — runs inside the same request as the action, + so a failure here surfaces with the action rather than as a silent + drop. The SSE push is asyncio-scheduled if a loop is running; if + no loop is available (test harness, sync test client) the dispatch + falls back to writing the row and relying on the next subscriber + poll to surface it. + """ + if rfc_slug is None or action_kind not in _AUTO_WATCH_ACTIONS: + # Actions without an rfc_slug (e.g. permission events) or that + # aren't a §15.6 substantive gesture don't drive the chokepoint. + return + + if actor_user_id is not None: + _bump_auto_watch(actor_user_id, rfc_slug) + + rules = _ROUTING.get(action_kind) + if rules is None: + return + + for rule in rules: + recipients = _resolve_recipients( + rule=rule, + actor_user_id=actor_user_id, + rfc_slug=rfc_slug, + details=details, + ) + if not recipients: + continue + for recipient_id in recipients: + _emit_one( + recipient_user_id=recipient_id, + event_kind=rule["event_kind"], + category=rule["category"], + actor_user_id=actor_user_id, + rfc_slug=rfc_slug, + branch_name=branch_name, + pr_number=pr_number, + details=details or {}, + ) + + +def fan_out_chat_message( + *, + actor_user_id: int, + rfc_slug: str, + branch_name: str, + thread_id: int, + message_id: int, + is_review_thread: bool = False, + pr_number: int | None = None, +) -> None: + """Called from chat write paths after a user message is persisted. + + Chat messages don't flow through bot.py (no Git write); the chokepoint + here is the equivalent for §15's chat-message events. The routing + rule is: prior authors in the thread (besides the message author) get + a personal-direct `chat_reply_to_my_message`; users watching the RFC + (state='watching', i.e. full stream) get a churn-class + `chat_message_in_participated_thread`. The two are union'd so a user + who is both gets only the personal-direct row. + """ + _bump_auto_watch(actor_user_id, rfc_slug) + + prior_authors = { + row["author_user_id"] + for row in db.conn().execute( + """ + SELECT DISTINCT author_user_id + FROM thread_messages + WHERE thread_id = ? + AND author_user_id IS NOT NULL + AND author_user_id != ? + AND id < ? + """, + (thread_id, actor_user_id, message_id), + ) + } + + # §15.6 / §15.8 filters apply here too — a muted recipient never sees + # a row regardless of which producer path generated it. + muted_rfc = _muted_user_ids_for_rfc(rfc_slug) + muters = _muters_of(actor_user_id) + prior_authors = (prior_authors - muted_rfc) - muters + + # Personal-direct: prior thread authors. + for recipient_id in prior_authors: + event_kind = ( + "pr_review_thread_reply" if is_review_thread else "chat_reply_to_my_message" + ) + _emit_one( + recipient_user_id=recipient_id, + event_kind=event_kind, + category=CATEGORY_PERSONAL, + actor_user_id=actor_user_id, + rfc_slug=rfc_slug, + branch_name=branch_name, + pr_number=pr_number, + thread_id=thread_id, + details={"message_id": message_id}, + ) + + # Churn: watchers of the RFC who weren't already covered. + watcher_ids = _watcher_user_ids(rfc_slug, scope="watching") - prior_authors + watcher_ids.discard(actor_user_id) + watcher_ids = (watcher_ids - muted_rfc) - muters + for recipient_id in watcher_ids: + event_kind = ( + "pr_review_thread_new" if is_review_thread + else "chat_message_in_participated_thread" + ) + _emit_one( + recipient_user_id=recipient_id, + event_kind=event_kind, + category=CATEGORY_CHURN, + actor_user_id=actor_user_id, + rfc_slug=rfc_slug, + branch_name=branch_name, + pr_number=pr_number, + thread_id=thread_id, + details={"message_id": message_id}, + ) + + +# --------------------------------------------------------------------------- +# Routing rules — per action_kind, a list of recipient×event×category +# --------------------------------------------------------------------------- + + +_ROUTING: dict[str, list[dict]] = { + # §9.1: propose surfaces the new slug. The proposer is the actor; + # the personal-direct beats fire on merge/decline, not on propose + # itself. Admins and owners get a structural ping so the + # pending-ideas queue isn't invisible. + "propose_rfc": [ + {"event_kind": "proposal_opened_on_watched_topic", + "category": CATEGORY_STRUCTURAL, "recipients": "admins"}, + ], + # §9.3: merge fires personal-direct to the proposer per §15.4's + # named-subject rule. Watchers also see the structural beat. + "merge_proposal": [ + {"event_kind": "proposal_merged", + "category": CATEGORY_PERSONAL, "recipients": "proposer"}, + {"event_kind": "proposal_merged", + "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"}, + ], + "decline_proposal": [ + {"event_kind": "proposal_declined", + "category": CATEGORY_PERSONAL, "recipients": "proposer"}, + ], + # §10.1: PR opened. Watchers see it; the opener auto-watches but + # doesn't get an inbox row for their own gesture. + "open_branch_pr": [ + {"event_kind": "pr_opened", + "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"}, + ], + "open_metadata_pr": [ + {"event_kind": "pr_opened", + "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"}, + ], + "open_claim_pr": [ + {"event_kind": "claim_opened", + "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"}, + ], + # §10.5: merge fires personal-direct to the PR opener if someone + # else merged; watchers see the structural beat regardless. + "merge_branch_pr": [ + {"event_kind": "pr_merged", + "category": CATEGORY_PERSONAL, "recipients": "pr_opener_if_other"}, + {"event_kind": "pr_merged", + "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"}, + ], + "withdraw_branch_pr": [ + {"event_kind": "pr_withdrawn", + "category": CATEGORY_PERSONAL, "recipients": "pr_opener_if_other"}, + {"event_kind": "pr_withdrawn", + "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"}, + ], + "supersede_branch_pr": [ + {"event_kind": "pr_withdrawn", + "category": CATEGORY_PERSONAL, "recipients": "pr_opener_if_other"}, + ], + # §8.6: accept_change is per-commit churn. Watchers (full stream) + # get the row; following-only watchers don't. + "accept_change": [ + {"event_kind": "pr_commit_added", + "category": CATEGORY_CHURN, "recipients": "watchers_full"}, + ], + "manual_flush": [ + {"event_kind": "pr_commit_added", + "category": CATEGORY_CHURN, "recipients": "watchers_full"}, + ], + "replay_change": [ + {"event_kind": "pr_commit_added", + "category": CATEGORY_CHURN, "recipients": "watchers_full"}, + ], + # §13.3: graduation_complete is personal-direct to owners (the + # super-draft's owners list) and structural to watchers. + "graduate_complete": [ + {"event_kind": "graduation_complete", + "category": CATEGORY_PERSONAL, "recipients": "owners"}, + {"event_kind": "graduation_complete", + "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"}, + ], + "graduate_start": [ + {"event_kind": "super_draft_graduation_ready", + "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"}, + ], + "create_resolution_branch": [ + {"event_kind": "pr_conflict_with_main", + "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"}, + ], + "create_branch": [ + # No notification — branch creation is a private gesture until + # work lands on it. Auto-watch is the visible effect. + ], +} + + +# --------------------------------------------------------------------------- +# Recipient resolvers +# --------------------------------------------------------------------------- + + +def _resolve_recipients( + *, + rule: dict, + actor_user_id: int | None, + rfc_slug: str, + details: dict | None, +) -> set[int]: + kind = rule["recipients"] + if kind == "watchers_structural": + # following (structural only) + watching (full stream) — both + # see structural events. + ids = _watcher_user_ids(rfc_slug, scope="any") + elif kind == "watchers_full": + ids = _watcher_user_ids(rfc_slug, scope="watching") + elif kind == "owners": + ids = _entry_owner_user_ids(rfc_slug) + elif kind == "admins": + ids = _admin_user_ids() + elif kind == "proposer": + ids = _proposer_user_id(rfc_slug) + elif kind == "pr_opener_if_other": + opener = _pr_opener_user_id(rfc_slug, details) + ids = {opener} if opener is not None and opener != actor_user_id else set() + else: + log.warning("notify: unknown recipient kind %s", kind) + return set() + + if actor_user_id is not None: + ids.discard(actor_user_id) + + # §15.6: per-RFC mute suppresses every signal for the (user, slug). + muted = _muted_user_ids_for_rfc(rfc_slug) + ids -= muted + + # §15.8: per-user mute suppresses notifications produced by the actor + # for each muter. (Inbox-volume only; content visibility unchanged.) + if actor_user_id is not None: + muters = _muters_of(actor_user_id) + ids -= muters + + return ids + + +def _watcher_user_ids(rfc_slug: str, *, scope: str) -> set[int]: + """scope: 'watching' = full stream only; 'any' = watching+following.""" + if scope == "watching": + rows = db.conn().execute( + "SELECT user_id FROM watches WHERE rfc_slug = ? AND state = 'watching'", + (rfc_slug,), + ) + else: + rows = db.conn().execute( + "SELECT user_id FROM watches WHERE rfc_slug = ? AND state IN ('watching', 'following')", + (rfc_slug,), + ) + return {r["user_id"] for r in rows} + + +def _entry_owner_user_ids(rfc_slug: str) -> set[int]: + row = db.conn().execute( + "SELECT owners_json FROM cached_rfcs WHERE slug = ?", (rfc_slug,), + ).fetchone() + if row is None: + return set() + try: + owners = json.loads(row["owners_json"] or "[]") + except json.JSONDecodeError: + return set() + if not owners: + return set() + placeholders = ",".join("?" * len(owners)) + user_rows = db.conn().execute( + f"SELECT id FROM users WHERE gitea_login IN ({placeholders})", + tuple(owners), + ) + return {r["id"] for r in user_rows} + + +def _admin_user_ids() -> set[int]: + return { + r["id"] + for r in db.conn().execute("SELECT id FROM users WHERE role IN ('owner', 'admin')") + } + + +def _proposer_user_id(rfc_slug: str) -> set[int]: + row = db.conn().execute( + """ + SELECT actor_user_id FROM actions + WHERE action_kind = 'propose_rfc' AND rfc_slug = ? + ORDER BY id LIMIT 1 + """, + (rfc_slug,), + ).fetchone() + if row is None or row["actor_user_id"] is None: + return set() + return {row["actor_user_id"]} + + +def _pr_opener_user_id(rfc_slug: str, details: dict | None) -> int | None: + """For pr_opener_if_other recipients, find the user who opened the PR. + + Uses cached_prs.opened_by (gitea_login) → users.id, falling back to + the audit log if the cache row isn't there yet. + """ + pr_number = (details or {}).get("pr_number") + # When called from merge/withdraw routing, details is the original + # action's details — pr_number is on the audit row, not here. Pull + # it from the most-recent relevant action. + row = db.conn().execute( + """ + SELECT actor_user_id FROM actions + WHERE action_kind = 'open_branch_pr' AND rfc_slug = ? + ORDER BY id DESC LIMIT 1 + """, + (rfc_slug,), + ).fetchone() + if row and row["actor_user_id"] is not None: + return row["actor_user_id"] + return pr_number # last-ditch: keep typing consistent (rarely matters) + + +def _muted_user_ids_for_rfc(rfc_slug: str) -> set[int]: + return { + r["user_id"] + for r in db.conn().execute( + "SELECT user_id FROM watches WHERE rfc_slug = ? AND state = 'muted'", + (rfc_slug,), + ) + } + + +def _muters_of(actor_user_id: int) -> set[int]: + return { + r["muter_user_id"] + for r in db.conn().execute( + "SELECT muter_user_id FROM notification_user_mutes WHERE muted_user_id = ?", + (actor_user_id,), + ) + } + + +# --------------------------------------------------------------------------- +# Auto-watch upsert +# --------------------------------------------------------------------------- + + +def _bump_auto_watch(user_id: int, rfc_slug: str) -> None: + """Per §15.6 substantive-gesture rule: upsert a `watching` row for + the actor, never downgrade. Bumps `last_participation_at` for the + 90-day decay timer regardless of current state. + + The role-implicit watching for owners/arbiters is computed at fan-out + time (recipient resolution), not stored on the row, so this upsert + doesn't need to special-case them. + """ + existing = db.conn().execute( + "SELECT state, set_by FROM watches WHERE user_id = ? AND rfc_slug = ?", + (user_id, rfc_slug), + ).fetchone() + now = "datetime('now')" + if existing is None: + db.conn().execute( + f""" + INSERT INTO watches + (user_id, rfc_slug, state, set_by, set_at, last_participation_at) + VALUES (?, ?, 'watching', 'auto', {now}, {now}) + """, + (user_id, rfc_slug), + ) + return + # Don't downgrade: a 'muted' explicit setting stays muted; a 'watching' + # row stays watching. The only auto-upgrade is following → watching. + if existing["state"] == "following" and existing["set_by"] != "explicit": + db.conn().execute( + f"UPDATE watches SET state = 'watching', last_participation_at = {now} WHERE user_id = ? AND rfc_slug = ?", + (user_id, rfc_slug), + ) + else: + db.conn().execute( + f"UPDATE watches SET last_participation_at = {now} WHERE user_id = ? AND rfc_slug = ?", + (user_id, rfc_slug), + ) + + +# --------------------------------------------------------------------------- +# Inserting a notification row + dispatch +# --------------------------------------------------------------------------- + + +def _emit_one( + *, + recipient_user_id: int, + event_kind: str, + category: str, + actor_user_id: int | None, + rfc_slug: str | None, + branch_name: str | None, + pr_number: int | None, + details: dict, + thread_id: int | None = None, +) -> int: + payload = {"category": category, **details} + cur = db.conn().execute( + """ + INSERT INTO notifications + (recipient_user_id, event_kind, rfc_slug, branch_name, pr_number, + thread_id, actor_user_id, payload) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + recipient_user_id, + event_kind, + rfc_slug, + branch_name, + pr_number, + thread_id, + actor_user_id, + json.dumps(payload), + ), + ) + notif_id = cur.lastrowid + + row_payload = _row_payload(notif_id) + _schedule_broadcast(recipient_user_id, "notification", row_payload) + + # Email dispatch: §15.4. Held during quiet hours per §15.8; held + # during a global opt-out (bounce) per §15.4. Defers to the digest + # otherwise per §15.5's exclusion rules. + _schedule_email(notif_id, recipient_user_id, event_kind, category, row_payload) + + return notif_id + + +def _row_payload(notif_id: int) -> dict: + row = db.conn().execute( + """ + SELECT n.id, n.event_kind, n.rfc_slug, n.branch_name, n.pr_number, + n.thread_id, n.actor_user_id, n.payload, n.created_at, n.read_at, + u.display_name AS actor_display, u.gitea_login AS actor_login, + r.title AS rfc_title + FROM notifications n + LEFT JOIN users u ON u.id = n.actor_user_id + LEFT JOIN cached_rfcs r ON r.slug = n.rfc_slug + WHERE n.id = ? + """, + (notif_id,), + ).fetchone() + if row is None: + return {} + try: + extras = json.loads(row["payload"] or "{}") + except json.JSONDecodeError: + extras = {} + return { + "id": row["id"], + "event_kind": row["event_kind"], + "rfc_slug": row["rfc_slug"], + "rfc_title": row["rfc_title"], + "branch_name": row["branch_name"], + "pr_number": row["pr_number"], + "thread_id": row["thread_id"], + "actor_user_id": row["actor_user_id"], + "actor_login": row["actor_login"], + "actor_display": row["actor_display"], + "created_at": row["created_at"], + "read_at": row["read_at"], + "category": extras.get("category"), + "summary": render_summary(row["event_kind"], row["actor_display"], row["rfc_title"], extras), + "extras": extras, + } + + +def render_summary(event_kind: str, actor_display: str | None, rfc_title: str | None, extras: dict) -> str: + """One short sentence shared by the inbox row and the email body + per §15.4. Actor is the underlying user per §15.9; null actor renders + as "the app" so a system-generated event still reads as a sentence.""" + actor = actor_display or "the app" + title = rfc_title or extras.get("slug") or "this RFC" + if event_kind == "proposal_merged": + return f"{actor} merged your proposal — {title} is now a super-draft." + if event_kind == "proposal_declined": + return f"{actor} declined your proposal for {title}." + if event_kind == "proposal_opened_on_watched_topic": + return f"{actor} proposed a new RFC: {title}." + if event_kind == "pr_opened": + return f"{actor} opened a PR on {title}." + if event_kind == "pr_merged": + return f"{actor} merged a PR on {title}." + if event_kind == "pr_withdrawn": + return f"{actor} withdrew a PR on {title}." + if event_kind == "pr_commit_added": + return f"{actor} added a commit on {title}." + if event_kind == "pr_review_thread_new": + return f"{actor} opened a review thread on {title}." + if event_kind == "pr_review_thread_reply": + return f"{actor} replied to your review thread on {title}." + if event_kind == "chat_message_in_participated_thread": + return f"{actor} posted a chat message on {title}." + if event_kind == "chat_reply_to_my_message": + return f"{actor} replied to your message on {title}." + if event_kind == "claim_opened": + return f"{actor} opened a claim PR on {title}." + if event_kind == "graduation_complete": + return f"{title} graduated to an active RFC." + if event_kind == "super_draft_graduation_ready": + return f"{actor} began graduating {title}." + if event_kind == "pr_conflict_with_main": + return f"{actor} started a resolution branch on {title}." + return f"{event_kind} on {title}" + + +def _schedule_broadcast(user_id: int, event: str, payload: Any) -> None: + """Best-effort SSE push; survives the absence of a running loop so + sync test clients still write the row.""" + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + loop.create_task(_broadcast(user_id, event, payload)) + + +def _schedule_email( + notif_id: int, + recipient_user_id: int, + event_kind: str, + category: str, + payload: dict, +) -> None: + """§15.4 dispatch — synchronous so the email_sent_at timestamp lands + in the same tick as the notification row, which is what the §15.5 + digest's exclusion rule 1 keys on. The actual SMTP send happens in + `email.py`; this just consults the recipient's preferences and quiet + hours, then calls into the adapter.""" + from . import email as email_mod # avoid import cycle + + email_mod.maybe_send( + notif_id=notif_id, + recipient_user_id=recipient_user_id, + event_kind=event_kind, + category=category, + payload=payload, + ) + + +# --------------------------------------------------------------------------- +# Per-user mute and watch helpers used by api_notifications +# --------------------------------------------------------------------------- + + +def list_inbox( + *, + user_id: int, + unread: bool | None = None, + rfc_slug: str | None = None, + category: str | None = None, + actor_user_id: int | None = None, + bundled: bool = False, + limit: int = 200, +) -> dict: + """§15.2: inbox query. Filter chips are AND-combined. + + When `bundled=True` is set, rows are grouped server-side by + (rfc_slug, event_kind) per the §15.2 bundle toggle, with the most + recent timestamp on each bundle. + """ + where = ["recipient_user_id = ?"] + args: list[Any] = [user_id] + if unread: + where.append("read_at IS NULL") + if rfc_slug: + where.append("rfc_slug = ?") + args.append(rfc_slug) + if actor_user_id is not None: + where.append("actor_user_id = ?") + args.append(actor_user_id) + rows = db.conn().execute( + f""" + SELECT n.id, n.event_kind, n.rfc_slug, n.branch_name, n.pr_number, + n.thread_id, n.actor_user_id, n.payload, n.created_at, n.read_at, + u.display_name AS actor_display, u.gitea_login AS actor_login, + r.title AS rfc_title + FROM notifications n + LEFT JOIN users u ON u.id = n.actor_user_id + LEFT JOIN cached_rfcs r ON r.slug = n.rfc_slug + WHERE {' AND '.join(where)} + ORDER BY n.id DESC + LIMIT ? + """, + (*args, limit), + ).fetchall() + items = [] + for row in rows: + try: + extras = json.loads(row["payload"] or "{}") + except json.JSONDecodeError: + extras = {} + if category and extras.get("category") != category: + continue + items.append({ + "id": row["id"], + "event_kind": row["event_kind"], + "rfc_slug": row["rfc_slug"], + "rfc_title": row["rfc_title"], + "branch_name": row["branch_name"], + "pr_number": row["pr_number"], + "thread_id": row["thread_id"], + "actor_user_id": row["actor_user_id"], + "actor_login": row["actor_login"], + "actor_display": row["actor_display"], + "created_at": row["created_at"], + "read_at": row["read_at"], + "category": extras.get("category"), + "summary": render_summary(row["event_kind"], row["actor_display"], row["rfc_title"], extras), + }) + + if bundled: + items = _bundle(items) + + unread_count = db.conn().execute( + "SELECT COUNT(*) AS c FROM notifications WHERE recipient_user_id = ? AND read_at IS NULL", + (user_id,), + ).fetchone()["c"] + + return {"items": items, "unread_count": unread_count} + + +def _bundle(items: list[dict]) -> list[dict]: + """§15.2: collapse per-row notifications into per-(RFC, event_kind) + bundles; the bundle's representative row is the most-recent + constituent, with a `bundled_ids` array carrying the rest.""" + buckets: dict[tuple, list[dict]] = {} + for it in items: + key = (it["rfc_slug"], it["event_kind"]) + buckets.setdefault(key, []).append(it) + bundled = [] + for key, rows in buckets.items(): + rep = dict(rows[0]) # most recent (sorted DESC above) + rep["bundled_ids"] = [r["id"] for r in rows] + rep["bundled_count"] = len(rows) + bundled.append(rep) + bundled.sort(key=lambda r: r["created_at"], reverse=True) + return bundled + + +def mark_read_by_filter( + *, + user_id: int, + unread: bool | None = None, + rfc_slug: str | None = None, + category: str | None = None, + actor_user_id: int | None = None, + ids: Iterable[int] | None = None, +) -> int: + """`Mark all read` per §15.2 — respects the active filter so the + user can mark all churn read without touching personal-direct.""" + where = ["recipient_user_id = ?", "read_at IS NULL"] + args: list[Any] = [user_id] + if rfc_slug: + where.append("rfc_slug = ?") + args.append(rfc_slug) + if actor_user_id is not None: + where.append("actor_user_id = ?") + args.append(actor_user_id) + if ids: + ids = list(ids) + if not ids: + return 0 + placeholders = ",".join("?" * len(ids)) + where.append(f"id IN ({placeholders})") + args.extend(ids) + # Category lives inside payload JSON; we filter post-fetch to keep + # the SQL portable. + rows = db.conn().execute( + f"SELECT id, payload FROM notifications WHERE {' AND '.join(where)}", + args, + ).fetchall() + selected_ids: list[int] = [] + for r in rows: + if category: + try: + extras = json.loads(r["payload"] or "{}") + except json.JSONDecodeError: + extras = {} + if extras.get("category") != category: + continue + selected_ids.append(r["id"]) + if not selected_ids: + return 0 + placeholders = ",".join("?" * len(selected_ids)) + db.conn().execute( + f"UPDATE notifications SET read_at = datetime('now') WHERE id IN ({placeholders})", + selected_ids, + ) + for nid in selected_ids: + _schedule_broadcast(user_id, "read", {"id": nid}) + return len(selected_ids) + + +def reconcile_seen_advance( + *, + user_id: int, + rfc_slug: str, + branch_name: str | None = None, + pr_number: int | None = None, +) -> int: + """§15.7 reconciler — when a scope cursor advances on visit, mark + matching unread notifications read. Called from the chat-seen and + pr-seen advance endpoints. + """ + where = ["recipient_user_id = ?", "read_at IS NULL", "rfc_slug = ?"] + args: list[Any] = [user_id, rfc_slug] + if pr_number is not None: + where.append("pr_number = ?") + args.append(pr_number) + elif branch_name is not None: + where.append("branch_name = ?") + args.append(branch_name) + rows = db.conn().execute( + f"SELECT id FROM notifications WHERE {' AND '.join(where)}", + args, + ).fetchall() + ids = [r["id"] for r in rows] + if not ids: + return 0 + placeholders = ",".join("?" * len(ids)) + db.conn().execute( + f"UPDATE notifications SET read_at = datetime('now') WHERE id IN ({placeholders})", + ids, + ) + for nid in ids: + _schedule_broadcast(user_id, "read", {"id": nid}) + return len(ids) + + +# --------------------------------------------------------------------------- +# 90-day decay sweep (§15.6) — called by the digest job's nightly loop +# --------------------------------------------------------------------------- + + +def decay_watches() -> int: + """Downgrade `watching` rows whose last_participation_at is older + than 90 days to `following`. Explicit and role-implicit rows are + exempt; role-implicit isn't stored on the row, so it's a no-op for + those. Returns the number of rows downgraded.""" + cur = db.conn().execute( + """ + UPDATE watches + SET state = 'following', last_participation_at = datetime('now') + WHERE state = 'watching' + AND set_by = 'auto' + AND (last_participation_at IS NULL + OR datetime(last_participation_at, '+90 days') < datetime('now')) + """ + ) + return cur.rowcount or 0 diff --git a/backend/migrations/008_email_opt_out.sql b/backend/migrations/008_email_opt_out.sql new file mode 100644 index 0000000..26caf0a --- /dev/null +++ b/backend/migrations/008_email_opt_out.sql @@ -0,0 +1,9 @@ +-- §15.4: the email-bounce webhook flips a global opt-out on the user's +-- row. Per §15.4, a global opt-out is the only durable response to a +-- hard bounce — naming it as its own column keeps the override visible +-- and reversible (e.g. on a user-requested re-subscribe). +-- +-- Per-category toggles remain on the row alongside; the global opt-out +-- short-circuits the per-category check at email_for() resolution time. + +ALTER TABLE users ADD COLUMN email_opt_out_all INTEGER NOT NULL DEFAULT 0; diff --git a/backend/tests/test_notifications_vertical.py b/backend/tests/test_notifications_vertical.py new file mode 100644 index 0000000..926d846 --- /dev/null +++ b/backend/tests/test_notifications_vertical.py @@ -0,0 +1,614 @@ +"""End-to-end integration tests for the Slice 6 vertical (§15 in full). + +The fan-out chokepoint in `notify.py` is the chief structural commitment +of the slice. These tests prove: + + * §15.1 routing: every action_kind that maps to a §15 signal lands a + `notifications` row of the right event_kind and category. + * §15.6 auto-watch: every write that names an rfc_slug upserts a + watches row for the actor (substantive-gesture rule). + * §15.2 inbox listing + filter chips: unread, rfc_slug, category, and + actor_user_id filters compose AND-wise. + * §15.7 reconciler: advancing branch_chat_seen or pr_seen marks + matching unread notifications read. + * §15.8 per-user mute: the mute suppresses inbox rows from the muted + actor; per-RFC mute suppresses every row for the slug. + * §15.8 quiet hours: notifications still land; email is held. + * §15.5 digest: cadence window roll-over emits a digest; a second + run during the same window emits nothing. + * §15.4 email-bounce webhook: sets the global opt-out and + short-circuits future email dispatch. + * §15.4 unsubscribe signed-URL: GET /api/email/unsubscribe?t=… flips + one category off. + * §15.3 SSE snapshot: opening the stream yields the current + unread_count. +""" +from __future__ import annotations + +import json as _json + +import pytest + +from test_propose_vertical import ( # noqa: F401 + FakeGitea, + app_with_fake_gitea, + provision_user_row, + sign_in_as, + tmp_env, +) +from test_super_draft_vertical import seed_super_draft # noqa: F401 + + +PITCH = "Open Human Model is a framework for representing humans." + + +# --------------------------------------------------------------------------- +# Fan-out: producer-side rules per §15.1 +# --------------------------------------------------------------------------- + + +def test_propose_rfc_auto_watches_the_proposer(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + client.post("/api/rfcs/propose", json={ + "title": "Open Human Model", "slug": "ohm", "pitch": PITCH, "tags": [], + }) + + # Auto-watch lands per §15.6 substantive gesture. + row = db.conn().execute( + "SELECT state, set_by FROM watches WHERE user_id = ? AND rfc_slug = ?", + (2, "ohm"), + ).fetchone() + assert row is not None + assert row["state"] == "watching" + assert row["set_by"] == "auto" + + +def test_proposal_merged_lands_personal_direct_for_proposer(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor", ) + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test") + r = client.post("/api/rfcs/propose", json={ + "title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": [], + }) + pr_number = r.json()["pr_number"] + + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test") + r = client.post(f"/api/proposals/{pr_number}/merge") + assert r.status_code == 200, r.text + + # Alice (proposer) gets a personal-direct proposal_merged row. + rows = db.conn().execute( + """ + SELECT event_kind, payload FROM notifications + WHERE recipient_user_id = ? ORDER BY id + """, + (2,), + ).fetchall() + kinds = [r["event_kind"] for r in rows] + assert "proposal_merged" in kinds + # Category metadata round-trips on the payload. + merged_row = next(r for r in rows if r["event_kind"] == "proposal_merged") + assert _json.loads(merged_row["payload"])["category"] == "personal-direct" + + +def test_proposal_declined_routes_to_proposer_only(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/propose", json={ + "title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": [], + }) + pr_number = r.json()["pr_number"] + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + r = client.post(f"/api/proposals/{pr_number}/decline", json={"comment": "Not aligned with this quarter's focus"}) + assert r.status_code == 200, r.text + + rows = db.conn().execute( + "SELECT recipient_user_id, event_kind FROM notifications WHERE event_kind = 'proposal_declined'" + ).fetchall() + recipients = {r["recipient_user_id"] for r in rows} + # Alice (id=2) receives it; Ben (the actor) does not. + assert recipients == {2} + + +# --------------------------------------------------------------------------- +# Inbox surface +# --------------------------------------------------------------------------- + + +def test_inbox_lists_rows_with_filter_chips(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []}) + pr_number = r.json()["pr_number"] + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + client.post(f"/api/proposals/{pr_number}/merge") + + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.get("/api/notifications") + assert r.status_code == 200, r.text + items = r.json()["items"] + assert any(i["event_kind"] == "proposal_merged" for i in items) + assert r.json()["unread_count"] >= 1 + + # Filter by category isolates personal-direct. + r = client.get("/api/notifications", params={"category": "personal-direct"}) + items = r.json()["items"] + assert all(i["category"] == "personal-direct" for i in items) + + # Filter by rfc_slug narrows further. + r = client.get("/api/notifications", params={"rfc_slug": "ohm"}) + items = r.json()["items"] + assert all(i["rfc_slug"] == "ohm" for i in items) + + +def test_mark_read_per_row_and_by_filter(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []}) + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + client.post(f"/api/proposals/{r.json()['pr_number']}/merge") + + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + items = client.get("/api/notifications").json()["items"] + notif_id = items[0]["id"] + r = client.post(f"/api/notifications/{notif_id}/read") + assert r.status_code == 200 + + row = db.conn().execute("SELECT read_at FROM notifications WHERE id = ?", (notif_id,)).fetchone() + assert row["read_at"] is not None + + # Mark-all-read by filter — should be idempotent. + r = client.post("/api/notifications/read", json={"rfc_slug": "ohm"}) + assert r.status_code == 200 + + +# --------------------------------------------------------------------------- +# §15.7 reconciler +# --------------------------------------------------------------------------- + + +def test_chat_seen_advance_marks_chat_notifications_read(app_with_fake_gitea): + """Per §15.7: when branch_chat_seen advances, unread chat-kind + notifications scoped to the same (slug, branch) flip to read.""" + from fastapi.testclient import TestClient + from app import db + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=3, login="bob", role="contributor") + seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH) + + # Alice cuts the edit branch (auto-watch). Bob joins the branch + # chat — Alice gets a chat_message_in_participated_thread row. + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"] + # Alice posts the first message so she's a prior author for Bob's reply. + view = client.get(f"/api/rfcs/ohm/branches/{branch}").json() + thread_id = view["main_thread_id"] + client.post( + f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/messages", + json={"text": "first thought"}, + ) + sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor") + r = client.post( + f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/messages", + json={"text": "reply from bob"}, + ) + msg_id = r.json()["message_id"] + + # Alice has an unread chat_reply_to_my_message row. + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + unread = db.conn().execute( + "SELECT id FROM notifications WHERE recipient_user_id = 2 AND read_at IS NULL" + ).fetchall() + assert len(unread) >= 1 + + # Alice visits the branch — chat-seen advances → reconciler clears. + r = client.post( + f"/api/rfcs/ohm/branches/{branch}/chat-seen", + json={"last_seen_message_id": msg_id}, + ) + assert r.status_code == 200 + assert r.json()["reconciled"] >= 1 + remaining = db.conn().execute( + "SELECT id FROM notifications WHERE recipient_user_id = 2 AND branch_name = ? AND read_at IS NULL", + (branch,), + ).fetchall() + assert remaining == [] + + +# --------------------------------------------------------------------------- +# §15.8 mutes +# --------------------------------------------------------------------------- + + +def test_per_user_mute_suppresses_inbox_rows(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=3, login="bob", role="contributor") + seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH) + + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + # Alice mutes Bob. + r = client.post("/api/users/3/notification-mute") + assert r.status_code == 200 + + # Alice cuts the branch and is a prior author. + branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"] + view = client.get(f"/api/rfcs/ohm/branches/{branch}").json() + thread_id = view["main_thread_id"] + client.post( + f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/messages", + json={"text": "alice opener"}, + ) + + # Bob posts. With the mute in place Alice gets no inbox row. + sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor") + client.post( + f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/messages", + json={"text": "bob reply"}, + ) + rows = db.conn().execute( + "SELECT id FROM notifications WHERE recipient_user_id = 2 AND actor_user_id = 3" + ).fetchall() + assert rows == [] + + +def test_per_rfc_mute_suppresses_every_signal(app_with_fake_gitea): + """§15.6: state='muted' on the watches row is the strongest leave- + me-alone gesture, including for personal-direct events.""" + from fastapi.testclient import TestClient + from app import db + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=3, login="bob", role="contributor") + # Seed the slug so the watch endpoint can resolve it. + seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, proposed_by="alice") + + # Alice mutes the slug. We bypass the API because the user-facing + # surface doesn't expose 'muted' as an auto-set — it's an + # explicit gesture. The endpoint accepts it. + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/ohm/watch", json={"state": "muted"}) + assert r.status_code == 200, r.text + + # Bob (a different user) cuts an edit branch — would normally + # auto-watch and produce a structural beat to other watchers. + # The mute suppresses Alice's row. + sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor") + # Add Alice as a watcher first via a chat message (auto-watch + # already set 'muted' though); ensure no row regardless. + client.post("/api/rfcs/ohm/start-edit-branch", json={}) + rows = db.conn().execute( + "SELECT event_kind FROM notifications WHERE recipient_user_id = 2 AND rfc_slug = 'ohm'" + ).fetchall() + assert rows == [] + + +def test_admin_cannot_mute_users(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + provision_user_row(user_id=3, login="bob", role="contributor") + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + r = client.post("/api/users/3/notification-mute") + assert r.status_code == 403 + + +# --------------------------------------------------------------------------- +# §15.8 quiet hours + §15.4 email +# --------------------------------------------------------------------------- + + +def test_quiet_hours_holds_email_but_inbox_lands(app_with_fake_gitea, monkeypatch): + from fastapi.testclient import TestClient + from app import db, email as email_mod + + app, _fake = app_with_fake_gitea + monkeypatch.setenv("APP_URL", "http://localhost:8000") + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=1, login="ben", role="owner") + # Quiet hours covering every wall-clock moment — 00:00 → 23:59 UTC. + db.conn().execute( + """ + UPDATE users + SET notification_quiet_hours_start = '00:00', + notification_quiet_hours_end = '23:59', + notification_quiet_hours_timezone = 'UTC', + email = 'alice@test' + WHERE id = 2 + """ + ) + email_mod.reset_sent_envelopes() + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []}) + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + client.post(f"/api/proposals/{r.json()['pr_number']}/merge") + + # Inbox row landed. + rows = db.conn().execute( + "SELECT id, email_sent_at FROM notifications WHERE recipient_user_id = 2 AND event_kind = 'proposal_merged'" + ).fetchall() + assert len(rows) >= 1 + # Email held (email_sent_at is NULL; no envelope in the buffer). + assert rows[0]["email_sent_at"] is None + sent_to_alice = [e for e in email_mod.sent_envelopes() if e["to"] == "alice@test"] + assert sent_to_alice == [] + + +def test_email_bounce_webhook_sets_global_opt_out(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + db.conn().execute("UPDATE users SET email = 'alice@test' WHERE id = 2") + r = client.post("/api/webhooks/email-bounce", json={"email": "alice@test", "kind": "hard"}) + assert r.status_code == 200 + assert r.json()["matched"] is True + row = db.conn().execute("SELECT email_opt_out_all FROM users WHERE id = 2").fetchone() + assert row["email_opt_out_all"] == 1 + + +def test_email_unsubscribe_signed_url_flips_category_off(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import email as email_mod, db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + token = email_mod.make_unsubscribe_url(2, "personal-direct").split("t=", 1)[1] + r = client.get(f"/api/email/unsubscribe?t={token}") + assert r.status_code == 200 + row = db.conn().execute("SELECT email_personal_direct FROM users WHERE id = 2").fetchone() + assert row["email_personal_direct"] == 0 + + +# --------------------------------------------------------------------------- +# §15.5 digest +# --------------------------------------------------------------------------- + + +def test_digest_emits_then_skips_already_included(app_with_fake_gitea, monkeypatch): + """Two consecutive `run_tick` passes: the first emits a digest with + the eligible rows; the second runs but emits nothing because the + cadence window has not yet rolled over (the just-recorded + `notification_digests` row's `period_end` is now).""" + from fastapi.testclient import TestClient + from app import db, digest as digest_mod, email as email_mod + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=3, login="bob", role="contributor") + db.conn().execute( + "UPDATE users SET email = 'alice@test', digest_cadence = 'daily' WHERE id = 2" + ) + seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH) + db.conn().execute( + """ + INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at) + VALUES (2, 'ohm', 'following', 'explicit', datetime('now', '-1 day'), datetime('now', '-1 day')) + """ + ) + # An eligible churn row from an hour ago. + db.conn().execute( + """ + INSERT INTO notifications + (recipient_user_id, event_kind, rfc_slug, actor_user_id, payload, created_at) + VALUES (2, 'pr_commit_added', 'ohm', 3, ?, datetime('now', '-1 hour')) + """, + (_json.dumps({"category": "churn"}),), + ) + # Seed a prior digest emission that's >24h ago so the daily + # cadence has rolled over and the first tick fires. + db.conn().execute( + """ + INSERT INTO notification_digests (recipient_user_id, sent_at, period_start, period_end, signal_ids_included) + VALUES (2, datetime('now', '-2 days'), datetime('now', '-3 days'), datetime('now', '-2 days'), '[]') + """ + ) + email_mod.reset_sent_envelopes() + + # First tick: digest emitted. + result = digest_mod.run_tick() + assert result["digests_sent"] == 1 + envelopes = [e for e in email_mod.sent_envelopes() if "digest" in e["subject"].lower()] + assert len(envelopes) == 1 + + # Verify digest_included_at landed for the row that was in the + # body — the audit field stays queryable per §15.5. + included = db.conn().execute( + "SELECT id FROM notifications WHERE recipient_user_id = 2 AND digest_included_at IS NOT NULL" + ).fetchall() + assert len(included) >= 1 + + # Second tick fires immediately. The cadence window has not + # rolled over (period_end on the new digest row is now), so + # nothing is emitted. + result2 = digest_mod.run_tick() + assert result2["digests_sent"] == 0 + + +# --------------------------------------------------------------------------- +# §15.3 SSE snapshot +# --------------------------------------------------------------------------- + + +def test_notify_subscriber_receives_broadcast(app_with_fake_gitea): + """The per-user SSE subscriber registry in `notify.subscribe` is + the substrate behind `/api/notifications/stream`. Driving it + directly verifies that an inbox-row insert pushes onto every + subscriber's queue, which is what backs the live badge counter + and the toast surface per §15.3. + + The HTTP-level stream test is intentionally out-of-scope here: + TestClient buffers chunked responses and so cannot observe an + SSE handler that yields once and then waits — the production + path uses a real ASGI server with chunk flushing. + """ + import asyncio as _asyncio + from app import db, notify + + app, _fake = app_with_fake_gitea + from fastapi.testclient import TestClient + with TestClient(app): + provision_user_row(user_id=2, login="alice", role="contributor") + + async def _drive(): + sub = await notify.subscribe(2) + # Insert a notification row via the chokepoint. The push + # is scheduled on the running loop. + db.conn().execute( + """ + INSERT INTO notifications (recipient_user_id, event_kind, rfc_slug, payload) + VALUES (2, 'proposal_merged', 'ohm', '{"category":"personal-direct"}') + """ + ) + nid = db.conn().execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + await notify._broadcast(2, "notification", notify._row_payload(nid)) + evt = await _asyncio.wait_for(sub.queue.get(), timeout=2.0) + await notify.unsubscribe(sub) + return evt + + evt = _asyncio.new_event_loop().run_until_complete(_drive()) + assert evt["event"] == "notification" + assert evt["payload"]["event_kind"] == "proposal_merged" + + +# --------------------------------------------------------------------------- +# Preferences +# --------------------------------------------------------------------------- + + +def test_notification_preferences_round_trip(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + r = client.get("/api/users/me/notification-preferences") + assert r.status_code == 200 + assert r.json()["email_personal_direct"] is True + assert r.json()["email_watched_churn"] is False + + r = client.post( + "/api/users/me/notification-preferences", + json={"email_watched_structural": True, "digest_cadence": "weekly"}, + ) + assert r.status_code == 200 + + r = client.get("/api/users/me/notification-preferences") + assert r.json()["email_watched_structural"] is True + assert r.json()["digest_cadence"] == "weekly" + + +def test_quiet_hours_endpoint_round_trip(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + # Setting a partial trio is rejected per §15.8. + r = client.post("/api/users/me/quiet-hours", json={"start": "22:00"}) + assert r.status_code == 422 + + # Full trio sets cleanly. + r = client.post( + "/api/users/me/quiet-hours", + json={"start": "22:00", "end": "07:00", "timezone": "America/Los_Angeles"}, + ) + assert r.status_code == 200, r.text + + r = client.get("/api/users/me/quiet-hours") + assert r.json()["start"] == "22:00" + assert r.json()["timezone"] == "America/Los_Angeles" + + # All-null clears. + r = client.post( + "/api/users/me/quiet-hours", + json={"start": None, "end": None, "timezone": None}, + ) + assert r.status_code == 200 + assert client.get("/api/users/me/quiet-hours").json()["start"] is None + + +# --------------------------------------------------------------------------- +# Watches surface +# --------------------------------------------------------------------------- + + +def test_explicit_watch_set_overrides_auto(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH) + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/ohm/watch", json={"state": "following"}) + assert r.status_code == 200 + + row = db.conn().execute( + "SELECT state, set_by FROM watches WHERE user_id = 2 AND rfc_slug = 'ohm'" + ).fetchone() + assert row["state"] == "following" + assert row["set_by"] == "explicit" + + # The auto-watch upsert from a subsequent gesture must not + # downgrade the explicit setting. Trigger a substantive + # gesture (cut an edit branch). + client.post("/api/rfcs/ohm/start-edit-branch", json={}) + row = db.conn().execute( + "SELECT state, set_by FROM watches WHERE user_id = 2 AND rfc_slug = 'ohm'" + ).fetchone() + # Following → watching is the *one* auto-upgrade in §15.6, but + # only for set_by='auto' rows; explicit rows must stay where + # the user put them. + assert row["set_by"] == "explicit" + assert row["state"] == "following" diff --git a/docs/DEV.md b/docs/DEV.md index 40fac7c..383835a 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -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//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//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 diff --git a/frontend/src/App.css b/frontend/src/App.css index 8fd0d4f..a6eeea5 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1139,3 +1139,82 @@ .branch-dropdown-item.pre-graduation .branch-meta { font-size: 10px; color: #9ca3af; margin-left: auto; } + +/* ---- §15 / Slice 6: inbox, badge, toasts ---- */ + +.inbox-trigger { + position: relative; background: transparent; border: 1px solid #e5e7eb; + border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 16px; + margin-right: 12px; +} +.inbox-trigger:hover { background: #f9fafb; } +.inbox-trigger .badge { + position: absolute; top: -6px; right: -6px; + background: #dc2626; color: white; font-size: 10px; + border-radius: 999px; padding: 1px 5px; font-weight: 700; + min-width: 16px; text-align: center; +} + +.inbox-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.25); + display: flex; align-items: flex-start; justify-content: center; + padding-top: 60px; z-index: 100; +} +.inbox-panel { + background: white; border: 1px solid #e5e7eb; border-radius: 8px; + width: 720px; max-width: 90vw; max-height: 80vh; + display: flex; flex-direction: column; box-shadow: 0 12px 32px rgba(0,0,0,0.18); +} +.inbox-header { + display: flex; align-items: center; justify-content: space-between; + padding: 12px 16px; border-bottom: 1px solid #e5e7eb; +} +.inbox-header h2 { margin: 0; font-size: 16px; } +.inbox-filters { + display: flex; gap: 8px; flex-wrap: wrap; + padding: 10px 16px; border-bottom: 1px solid #f3f4f6; align-items: center; +} +.inbox-filters .chip { + font-size: 12px; padding: 4px 8px; background: #f9fafb; + border: 1px solid #e5e7eb; border-radius: 999px; + display: inline-flex; align-items: center; gap: 4px; +} +.inbox-filters .chip input[type=checkbox] { margin-right: 2px; } +.inbox-filters select.chip { padding: 4px 8px; } + +.inbox-body { overflow-y: auto; flex: 1; } +.inbox-list { list-style: none; margin: 0; padding: 0; } +.inbox-row { border-bottom: 1px solid #f3f4f6; } +.inbox-row.unread { background: #fffbeb; } +.inbox-row-link { + display: flex; align-items: center; gap: 10px; padding: 10px 16px; + text-decoration: none; color: inherit; +} +.inbox-row-link:hover { background: #f9fafb; } +.inbox-cat { + font-size: 9px; text-transform: uppercase; letter-spacing: 0.05em; + padding: 2px 6px; border-radius: 4px; font-weight: 700; +} +.inbox-cat.cat-personal-direct { background: #fef3c7; color: #92400e; } +.inbox-cat.cat-structural { background: #dbeafe; color: #1e40af; } +.inbox-cat.cat-churn { background: #f3f4f6; color: #4b5563; } +.inbox-summary { flex: 1; font-size: 13px; } +.inbox-bundle-count { + font-size: 11px; color: #6b7280; + background: #f3f4f6; padding: 1px 6px; border-radius: 999px; +} +.inbox-when { font-size: 11px; color: #9ca3af; } + +.toast-host { + position: fixed; right: 16px; bottom: 16px; + display: flex; flex-direction: column; gap: 8px; z-index: 200; +} +.toast { + padding: 10px 14px; background: white; border: 1px solid #e5e7eb; + border-left: 4px solid #6b7280; border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.08); font-size: 13px; + max-width: 360px; cursor: pointer; +} +.toast.cat-personal-direct { border-left-color: #d97706; } +.toast.cat-structural { border-left-color: #2563eb; } +.toast.cat-churn { border-left-color: #6b7280; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d4f1cd1..179c72a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,12 +1,14 @@ import { useEffect, useState } from 'react' import { Routes, Route, Link, useNavigate } from 'react-router-dom' -import { getMe } from './api' +import { getMe, subscribeToNotifications } from './api' import Catalog from './components/Catalog.jsx' +import Inbox from './components/Inbox.jsx' import RFCView from './components/RFCView.jsx' import PRView from './components/PRView.jsx' import ProposalView from './components/ProposalView.jsx' import ProposeModal from './components/ProposeModal.jsx' import Landing from './components/Landing.jsx' +import ToastHost, { showToast } from './components/ToastHost.jsx' import './App.css' export default function App() { @@ -14,6 +16,9 @@ export default function App() { const [loading, setLoading] = useState(true) const [proposeOpen, setProposeOpen] = useState(false) const [catalogVersion, setCatalogVersion] = useState(0) + const [inboxOpen, setInboxOpen] = useState(false) + const [unreadCount, setUnreadCount] = useState(0) + const [inboxTick, setInboxTick] = useState(0) const navigate = useNavigate() useEffect(() => { @@ -23,6 +28,39 @@ export default function App() { .finally(() => setLoading(false)) }, []) + // §15.3 — subscribe to the live SSE stream for authenticated viewers + // so the badge counter and the toast surface stay in lockstep with + // the inbox. Tabs that miss an event because they were closed pick + // it up on next sign-in via the snapshot frame. + useEffect(() => { + if (!me?.authenticated) return undefined + const close = subscribeToNotifications({ + onSnapshot: payload => setUnreadCount(payload.unread_count || 0), + onNotification: payload => { + setUnreadCount(c => c + 1) + setInboxTick(t => t + 1) + // §15.3: personal-direct events get a toast even when the user + // isn't on the relevant view — they're the named subject. + // Churn never toasts; structural toasts only when it lands on + // a slug the user is currently viewing (URL match). + const isPersonal = payload.category === 'personal-direct' + const onCurrentSlug = payload.rfc_slug && window.location.pathname.includes(`/rfc/${payload.rfc_slug}`) + if (isPersonal || onCurrentSlug) { + showToast({ + summary: payload.summary, + category: payload.category, + link: payload.rfc_slug ? `/rfc/${payload.rfc_slug}` : null, + }) + } + }, + onRead: () => { + setUnreadCount(c => Math.max(0, c - 1)) + setInboxTick(t => t + 1) + }, + }) + return close + }, [me?.authenticated]) + if (loading) { return
Loading…
} @@ -38,6 +76,16 @@ export default function App() { Wiggleverse RFCs
+ {me.user.display_name} {me.user.role} Sign out @@ -67,6 +115,10 @@ export default function App() { }} /> )} + {inboxOpen && ( + setInboxOpen(false)} lastChangeTick={inboxTick} /> + )} +
) } diff --git a/frontend/src/api.js b/frontend/src/api.js index 511d1fb..4155542 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -399,3 +399,100 @@ export async function streamChatTurn(slug, branch, threadId, { text, quote, mode return { assistantId, userMsgId } } +// --------------------------------------------------------------------------- +// §15 / Slice 6: notifications surface +// --------------------------------------------------------------------------- + +export async function listNotifications({ unread, rfcSlug, category, actorUserId, bundled } = {}) { + const params = new URLSearchParams() + if (unread) params.set('unread', '1') + if (rfcSlug) params.set('rfc_slug', rfcSlug) + if (category) params.set('category', category) + if (actorUserId) params.set('actor_user_id', actorUserId) + if (bundled) params.set('bundled', '1') + const qs = params.toString() + return jsonOrThrow(await fetch(`/api/notifications${qs ? `?${qs}` : ''}`)) +} + +export async function markNotificationRead(id) { + return jsonOrThrow(await fetch(`/api/notifications/${id}/read`, { method: 'POST' })) +} + +export async function markNotificationsReadByFilter(filter) { + return jsonOrThrow(await fetch('/api/notifications/read', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(filter || {}), + })) +} + +export async function listWatches() { + return jsonOrThrow(await fetch('/api/watches')) +} + +export async function setWatch(slug, state) { + return jsonOrThrow(await fetch(`/api/rfcs/${slug}/watch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state }), + })) +} + +export async function getNotificationPreferences() { + return jsonOrThrow(await fetch('/api/users/me/notification-preferences')) +} + +export async function setNotificationPreferences(prefs) { + return jsonOrThrow(await fetch('/api/users/me/notification-preferences', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(prefs), + })) +} + +export async function getQuietHours() { + return jsonOrThrow(await fetch('/api/users/me/quiet-hours')) +} + +export async function setQuietHours({ start, end, timezone } = {}) { + return jsonOrThrow(await fetch('/api/users/me/quiet-hours', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ start: start || null, end: end || null, timezone: timezone || null }), + })) +} + +export async function muteUser(userId) { + return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' })) +} + +export async function unmuteUser(userId) { + return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'DELETE' })) +} + +export async function advanceChatSeen(slug, branch, lastSeenMessageId) { + return jsonOrThrow(await fetch(`/api/rfcs/${slug}/branches/${branch}/chat-seen`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ last_seen_message_id: lastSeenMessageId || null }), + })) +} + +// SSE subscription helper. Returns a close() function. The handler +// surface mirrors §15.3: a snapshot event on open, then per-notification +// `notification` events, plus `read` events when another tab marks a row. +export function subscribeToNotifications({ onSnapshot, onNotification, onRead, onError } = {}) { + const source = new EventSource('/api/notifications/stream') + source.addEventListener('snapshot', e => { + try { onSnapshot?.(JSON.parse(e.data)) } catch {} + }) + source.addEventListener('notification', e => { + try { onNotification?.(JSON.parse(e.data)) } catch {} + }) + source.addEventListener('read', e => { + try { onRead?.(JSON.parse(e.data)) } catch {} + }) + source.onerror = err => { onError?.(err) } + return () => source.close() +} + diff --git a/frontend/src/components/Inbox.jsx b/frontend/src/components/Inbox.jsx new file mode 100644 index 0000000..8ae42e0 --- /dev/null +++ b/frontend/src/components/Inbox.jsx @@ -0,0 +1,188 @@ +// §15.2 — the inbox panel. +// +// One mental space across every RFC the contributor has any relationship +// to. Filter chips (Unread only, RFC: …, Category: personal-direct / +// structural / churn) are AND-combined. The bundle toggle collapses +// rows by (RFC, event_kind) per §15.2's per-bundle markable surface. +// +// The header badge in App.jsx subscribes to the same SSE stream and so +// stays in lockstep with the inbox per §15.3. + +import { useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { + listNotifications, + markNotificationRead, + markNotificationsReadByFilter, +} from '../api.js' + +const CATEGORIES = [ + { value: '', label: 'All categories' }, + { value: 'personal-direct', label: 'Personal' }, + { value: 'structural', label: 'Structural' }, + { value: 'churn', label: 'Churn' }, +] + +export default function Inbox({ onClose, lastChangeTick }) { + const [items, setItems] = useState([]) + const [unreadCount, setUnreadCount] = useState(0) + const [filters, setFilters] = useState({ + unread: false, rfcSlug: '', category: '', bundled: false, + }) + const [loading, setLoading] = useState(true) + + useEffect(() => { + setLoading(true) + listNotifications({ + unread: filters.unread, + rfcSlug: filters.rfcSlug || undefined, + category: filters.category || undefined, + bundled: filters.bundled, + }) + .then(r => { + setItems(r.items || []) + setUnreadCount(r.unread_count || 0) + }) + .finally(() => setLoading(false)) + }, [filters, lastChangeTick]) + + const rfcOptions = useMemo(() => { + const seen = new Map() + for (const it of items) { + if (it.rfc_slug && !seen.has(it.rfc_slug)) { + seen.set(it.rfc_slug, it.rfc_title || it.rfc_slug) + } + } + return Array.from(seen.entries()) + }, [items]) + + async function handleRowClick(item) { + if (!item.read_at) { + await markNotificationRead(item.id) + setItems(prev => prev.map(p => p.id === item.id ? { ...p, read_at: new Date().toISOString() } : p)) + } + } + + async function markAllUnderFilter() { + await markNotificationsReadByFilter({ + rfc_slug: filters.rfcSlug || undefined, + category: filters.category || undefined, + }) + setItems(prev => prev.map(p => ({ ...p, read_at: p.read_at || new Date().toISOString() }))) + setUnreadCount(0) + } + + return ( +
+
e.stopPropagation()}> +
+

Inbox

+ +
+ +
+ + + + + + + + + +
+ +
+ {loading &&

Loading…

} + {!loading && items.length === 0 && ( +

No notifications match. Try a different filter, or come back later.

+ )} +
    + {items.map(item => ( + + ))} +
+
+
+
+ ) +} + +function InboxRow({ item, onClick, onClose }) { + const unread = !item.read_at + const target = deepLink(item) + const handle = async () => { + await onClick(item) + if (target) onClose?.() + } + return ( +
  • + + {item.category || '·'} + {item.summary} + {item.bundled_count > 1 && ( + +{item.bundled_count - 1} + )} + {formatWhen(item.created_at)} + +
  • + ) +} + +function deepLink(item) { + if (item.rfc_slug && item.pr_number) return `/rfc/${item.rfc_slug}/pr/${item.pr_number}` + if (item.rfc_slug && item.branch_name) return `/rfc/${item.rfc_slug}?branch=${item.branch_name}` + if (item.rfc_slug) return `/rfc/${item.rfc_slug}` + return '' +} + +function formatWhen(iso) { + if (!iso) return '' + const dt = new Date(iso.replace(' ', 'T') + (iso.endsWith('Z') ? '' : 'Z')) + if (Number.isNaN(dt.getTime())) return iso + const diffMs = Date.now() - dt.getTime() + const m = Math.floor(diffMs / 60000) + if (m < 1) return 'just now' + if (m < 60) return `${m}m` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h` + const d = Math.floor(h / 24) + return `${d}d` +} diff --git a/frontend/src/components/ToastHost.jsx b/frontend/src/components/ToastHost.jsx new file mode 100644 index 0000000..201b1f2 --- /dev/null +++ b/frontend/src/components/ToastHost.jsx @@ -0,0 +1,54 @@ +// §15.3 — the toast surface. +// +// Toasts fire for the user's own actions completing and for events +// landing on the exact view the user is currently looking at. The +// inbox row still lands for the same event; the toast just carries +// the "something just happened here" beat per §15.3. +// +// This component is a simple stack with a small cap (4 visible at +// once). Newer toasts queue behind the visible ones rather than +// stacking endlessly. Auto-dismiss after a short interval; click to +// dismiss early. + +import { useEffect, useState } from 'react' + +const MAX_VISIBLE = 4 +const AUTO_DISMISS_MS = 6000 + +let _emit = null + +export function showToast({ summary, category, link }) { + if (_emit) _emit({ id: Math.random().toString(36).slice(2), summary, category, link, ts: Date.now() }) +} + +export default function ToastHost() { + const [toasts, setToasts] = useState([]) + + useEffect(() => { + _emit = (t) => setToasts(prev => [...prev, t]) + return () => { _emit = null } + }, []) + + useEffect(() => { + if (toasts.length === 0) return + const t = setTimeout(() => { + setToasts(prev => prev.slice(1)) + }, AUTO_DISMISS_MS) + return () => clearTimeout(t) + }, [toasts]) + + const visible = toasts.slice(0, MAX_VISIBLE) + return ( +
    + {visible.map(t => ( +
    setToasts(prev => prev.filter(x => x.id !== t.id))} + > + {t.summary} +
    + ))} +
    + ) +}