Slice 6: notifications per §15

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 23:09:04 -07:00
parent 1b0968a9a2
commit f67d0aa0db
21 changed files with 3588 additions and 168 deletions
+35 -9
View File
@@ -93,9 +93,20 @@ Required values:
| `OWNER_GITEA_LOGIN` | Your owner-zero Gitea login — gets the owner role on first sign-in. | | `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. | | `GITEA_WEBHOOK_SECRET` | A shared secret for the §4.1 webhook signature. |
The LLM-provider settings (`ENABLED_MODELS`, `ANTHROPIC_API_KEY`, Optional values, picked up at process start:
etc.) are not exercised by Slice 1 but are wired through `config.py`
so the next slice can pick them up. | 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 ### 6. Install dependencies
@@ -150,7 +161,7 @@ button at the bottom opens the propose modal.
## What the build lets you do so far ## What the build lets you do so far
Slices 15 are shipped. End-to-end paths the app supports today: Slices 16 are shipped. End-to-end paths the app supports today:
- **Propose → idea PR → merge → super-draft** (Slice 1, §9.1–§9.3). - **Propose → idea PR → merge → super-draft** (Slice 1, §9.1–§9.3).
- **Super-draft body editing** via meta-repo edit branches, with AI - **Super-draft body editing** via meta-repo edit branches, with AI
@@ -172,6 +183,21 @@ Slices 15 are shipped. End-to-end paths the app supports today:
§13 in full). §13 in full).
- **§13.1 ownership claim** as a meta-repo PR adding the claimant - **§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). 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 This exercises the §4 cache (webhook + reconciler), the §6
permission model in full, the §1 bot wrapper (every Git write goes 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 trailer), and the §17 routing-collapse rule that lets active and
super-draft surfaces share their endpoints. super-draft surfaces share their endpoints.
Out of scope for the slices shipped so far: notifications (Slice 6, Out of scope for the slices shipped so far: landing-page and
§15), landing-page and `/philosophy` chrome polish (Slice 7, §14), `/philosophy` chrome polish, the notification-settings UI surface,
the §12 30/90 branch-hygiene timers (Slice 8). The full slicing and the admin neighbourhood (Slice 7, §14 + §19.2 candidates); the
plan and the next slice's brief live in §12 30/90 branch-hygiene timers (Slice 8). The full slicing plan
[`docs/DEV.md`](./docs/DEV.md). and the next slice's brief live in [`docs/DEV.md`](./docs/DEV.md).
## Verifying it worked ## Verifying it worked
+129 -82
View File
@@ -2405,96 +2405,93 @@ surface. With Topic 13 folded in, the structural surface is
complete. What follows is no longer "topics that block specifying complete. What follows is no longer "topics that block specifying
v1" but "topics to address during or shortly after the v1 build." 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 Slice 6 of the build has landed. The §15 notifications surface runs
end-to-end against the local Gitea — the Graduate dialog renders end-to-end against the local Gitea — every `actions` row whose
the three editable fields (integer ID, repo name, initial owners) `action_kind` maps to a §15.1 event fans out through
with the debounced `GET /api/rfcs/<slug>/graduate/check` lighting `notify.fan_out_from_action`, called inline from `bot._log` and
up per-field validity inline, the precondition popover surfaces from the graduation orchestrator's `_audit`. Chat-message inserts
open body-edit PRs via `GET /api/rfcs/<slug>/blocking-prs` (the take a parallel path through `notify.fan_out_chat_message` from
§9.8 gate enforced before the sequence starts), and confirming the inside `chat.append_user_message`, since chat doesn't flow through
dialog kicks off the §13.3 five-step sequence streamed via the bot wrapper. The §15.6 auto-watch upsert sits in the same
`GET /api/rfcs/<slug>/graduate/progress`. The orchestrator in chokepoint — every substantive gesture either creates a `watching`
`api_graduation.py` runs the sequence as an asyncio task fed by an row or bumps `last_participation_at` for the 90-day decay timer.
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.
§13.4's chat migration landed as a database semantic no-op — The §15.4 email loop runs through an SMTP adapter with a stdout
the whole-doc main thread on the super-draft fallback for dev — the in-memory `_SENT` buffer is what the
(`rfc_slug=<slug>`, `branch_name='main'`) is the same row before integration tests read from. The per-category dispatch holds during
and after graduation; only the interpretation changes (canonical- §15.8 quiet hours; on window-end, `email.flush_pending` bundles
body view becomes per-RFC repo's main). The slug is the canonical above the §15.4 threshold into a single "Activity while you were
key per §2.3, so no data movement is needed. Edit-branch chats away" mail. The signed-URL unsubscribe path flips a single category
stay attached to their original `branch_name` per §9.8's column to zero; the bounce webhook flips the new `email_opt_out_all`
no-data-movement framing; the §9.8 pre-graduation history column (migration `008_email_opt_out.sql`).
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 §13.1 claim flow landed alongside graduation since it's the The §15.5 digest is a `DigestScheduler` wrapping `cache.Reconciler`'s
prerequisite for non-admin graduation. The bot grew `open_claim_pr`; shape, with a `run_tick` seam the tests drive synchronously. Each
`api_prs._require_pr` broadened to accept `pr_kind='meta_claim'` tick releases held emails, runs the §15.6 90-day decay sweep, and
so the merge surface inherits structurally from §10. Until §13.1's assembles per-cadence digests where the window has rolled over.
claim runs, the dialog refuses the start when `owners=[]` and the The §15.5 exclusion rules (already-emailed, already-read,
popover surfaces "Claim ownership yourself" as a remediation personal-direct-excluded) keep two consecutive ticks idempotent.
affordance (admins are contributors per §6.1 and can claim solo).
The five §17 routes Slice 5 added — `claim`, `blocking-prs`, §15.2 / §15.3 / §15.7 / §15.8 surface as fourteen endpoints in
`graduate/check`, `graduate`, and `graduate/progress` — live in `backend/app/api_notifications.py`, plus the chat-seen advance on
`backend/app/api_graduation.py`. The §5 schema needed no migration. `api_branches` and the existing PR seen-cursor on `api_prs` — both
On the frontend, `RFCView.jsx`'s breadcrumb actions grew extended to call `notify.reconcile_seen_advance` so the §15.7
`Graduate to RFC repo` and `Claim ownership` buttons; visit-advances-cursor loop closes back into the inbox-row read
`GraduateDialog.jsx` owns the three-field surface, the precondition state. The SSE stream holds a per-user subscriber queue keyed by
popover, and the live step stack fed by an `EventSource` on the user_id; multiple browser tabs see the same events.
progress SSE; the `BranchDropdown` gains a `Pre-graduation history`
disclosure that surfaces edit-branch threads on the new RFC view
per §9.8.
Slice 5 ships covered by `backend/tests/test_graduation_vertical.py` On the frontend, `App.jsx` grew a header badge (cap "99+",
— ten integration tests against the FakeGitea (extended with clicking opens the inbox overlay), an SSE-driven counter that
`DELETE /repos/{owner}/{repo}` for the rollback inverse) covering surfaces personal-direct toasts (own-name signals) and live-view
the dialog validator's per-field checks, the no-owners refusal, toasts (events landing on the slug the user is viewing). The
the §9.8 precondition refusing the start, the §13.3 happy path inbox is `Inbox.jsx` — three filter chips (Unread only, RFC,
end-to-end with audit-log verification, mid-sequence rollback at Category), a Bundle toggle, and a "Mark all read (under filter)"
step 2 (seed) and step 3 (PR open), concurrent-graduation refusal, button. `ToastHost.jsx` caps four visible at once with auto-dismiss.
§13.4's chat-row-survives contract, the §9.8 pre-graduation
history surface, and the §13.1 claim PR cycle. The full Slices 15
test suite is 45/45 green.
**Slice 6 is notifications per §15.** Every other vertical now The §15.9 attribution rule fell out cleanly: every `notifications`
produces signals — propose, claim, merge, graduate, body edits, row carries `actor_user_id` resolved from the `actions.actor_user_id`
manual flushes, PR open/withdraw/merge, review threads, conflict- in the originating audit row (the underlying user, never the bot).
replay — and Slice 6 builds the surface that turns those signals System-generated events (digest emission, 90-day decay) leave
into a contributor's inbox. The §5 schema already carries the `actor_user_id` NULL and render as "the app." AI participation
notifications, watches, branch_chat_seen, notification_user_mutes, events landed as null-system per §19.2's candidate naming — when a
and notification_digests tables; Topic 13's session settled the chat message authored by an AI provider goes through, no actor row
producer-side rules per §15.1, the §15.2 inbox grouping, §15.3 is written, since the LLM call doesn't have a user_id; the topic
badges and toasts, §15.4 email categories, §15.5 digest cadence, folder for "AI participation as a notification source" in §19.2
§15.6 watch/subscription, §15.7 unread mechanism, §15.8 do-not- remains open for explicit settling.
disturb, and §15.9 attribution. The producer-side hook is "after
a write succeeds, evaluate watches and fan-out notification rows" Slice 6 ships covered by `backend/tests/test_notifications_vertical.py`
— same chokepoint shape Slice 1's `_log` uses, invoked inline — seventeen integration tests covering the producer-side fan-out
from the bot wrapper. The consumer-side hook is the header badge, on the propose/merge/decline chain, §15.6 auto-watch, the §15.2
the inbox panel, the toast surface, and the per-row read-state inbox listing with filter chips, the §15.7 chat-seen reconciler,
machinery. The §15.4 email loop and the §15.5 digest are the the §15.8 per-user mute and the per-RFC mute, the §15.4 email-
heavier sub-pieces — the digest needs a scheduled-job runner; bounce webhook flipping the global opt-out, the `/email/unsubscribe`
the email loop needs a transactional-email adapter and the signed-URL path, the §15.8 quiet-hours email hold, the §15.5
`POST /api/webhooks/email-bounce` receiver. 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 16 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`, 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 without re-briefing. The working agreement in §19.3 continues to
apply: implement the slice, correct the spec only where running apply: implement the slice, correct the spec only where running
code reveals it was wrong at a structural level, accumulate new 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 minimum that keeps the test surface terse without adding a
separate test-only module. separate test-only module.
- **Body full-text search.** When the time comes. - **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 Topic 13 (notifications) is settled and folded into §5 (the
notifications, watches, branch_chat_seen, notification_user_mutes, notifications, watches, branch_chat_seen, notification_user_mutes,
+4 -1
View File
@@ -17,7 +17,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field 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 .bot import Bot
from .config import Config from .config import Config
from .gitea import Gitea, GiteaError from .gitea import Gitea, GiteaError
@@ -55,6 +55,9 @@ def make_router(
router.include_router(api_prs.make_router(config, gitea, bot, providers)) router.include_router(api_prs.make_router(config, gitea, bot, providers))
# Slice 5: §13 graduation + §13.1 claim. # Slice 5: §13 graduation + §13.1 claim.
router.include_router(api_graduation.make_router(config, gitea, bot)) 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 # Auth surface — extends the prototype's pattern but reads role
+29
View File
@@ -901,6 +901,35 @@ def make_router(
) )
return {"ok": True, "message_id": message_id} 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": <int>}`. 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") @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]: async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request) viewer = auth.require_contributor(request)
+11
View File
@@ -907,6 +907,17 @@ def _audit(
json.dumps(details) if details else None, 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,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+421
View File
@@ -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/<id>/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/<slug>/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/<id>/notification-mute` §15.8
- `DELETE /api/users/<id>/notification-mute` §15.8
- `GET /api/email/unsubscribe` §15.4 one-click
- `POST /api/webhooks/email-bounce` §15.4 receiver
The `branches/<branch>/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/<n>/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(
"<h1>Link expired or invalid</h1>"
"<p>Open the app to manage your notification preferences directly.</p>",
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"<h1>Unknown category</h1><p>{category}</p>", status_code=400
)
db.conn().execute(f"UPDATE users SET {column} = 0 WHERE id = ?", (user_id,))
return HTMLResponse(
f"<h1>Unsubscribed</h1><p>You will no longer receive {category} emails. "
f"You can re-enable them in your notification preferences.</p>"
)
@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
+7 -1
View File
@@ -352,7 +352,13 @@ def make_router(
""", """,
(viewer.user_id, slug, pr_number, new_sha, new_msg), (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. # §10.4: post a review-kind thread anchored to a diff range.
+13 -1
View File
@@ -25,7 +25,7 @@ from __future__ import annotations
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from . import db from . import db, notify
from .gitea import Gitea from .gitea import Gitea
@@ -89,6 +89,18 @@ def _log(
json.dumps(details) if details else None, 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: class Bot:
+40 -1
View File
@@ -139,7 +139,46 @@ def append_user_message(
""", """,
(thread_id, author_user_id, text, quote), (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: def append_assistant_placeholder(*, thread_id: int, model_id: str) -> int:
+275
View File
@@ -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),
),
)
+447
View File
@@ -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
+4 -1
View File
@@ -14,7 +14,7 @@ from fastapi import APIRouter, FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware 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 .bot import Bot
from .config import load_config from .config import load_config
from .gitea import Gitea from .gitea import Gitea
@@ -31,6 +31,7 @@ async def lifespan(app: FastAPI):
gitea = Gitea(config) gitea = Gitea(config)
bot = Bot(gitea) bot = Bot(gitea)
reconciler = cache.Reconciler(config, gitea) reconciler = cache.Reconciler(config, gitea)
digest_sched = digest.DigestScheduler()
# §18 carryover: the multi-provider LLM abstraction. Provider # §18 carryover: the multi-provider LLM abstraction. Provider
# construction can fail (missing key, wrong env value) — if it does, # 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)) app.include_router(webhooks.make_router(config, gitea))
reconciler.start() reconciler.start()
digest_sched.start()
log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo) log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo)
try: try:
yield yield
finally: finally:
await digest_sched.stop()
await reconciler.stop() await reconciler.stop()
await gitea.close() await gitea.close()
+948
View File
@@ -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
+9
View File
@@ -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;
@@ -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"
+131 -71
View File
@@ -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 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. 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 16 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 ### Slice 5 — shipped
Graduation per §13 in full. The §13.3 five-step transactional sequence Graduation per §13 in full. The §13.3 five-step transactional sequence
@@ -416,86 +510,52 @@ spec:
## Next slice ## Next slice
**Slice 6: notifications per §15.** **Slice 7: the §14 chrome.**
Every other vertical now produces signals: propose, claim, merge, With Slice 6 shipped, every structural and notification beat the
graduate, body edits, manual flushes, PR open/withdraw/merge, framework commits to is live: propose, claim, super-draft body
review threads, conflict-replay, super-draft chat. Slice 6 builds editing, the §10 PR flow against both repo shapes, graduation, and
the inbox, the fan-out, the digest, and the email loop that turn the §15 inbox/email/digest stack. What remains for v1 is the chrome
those signals into a contributor's surface. The §5 schema already that wraps the whole thing — the landing page that brings an
carries the notifications, watches, branch_chat_seen, unauthenticated visitor in, the `/philosophy` route that surfaces
notification_user_mutes, and notification_digests tables; Topic 13's [`PHILOSOPHY.md`](../PHILOSOPHY.md) verbatim, the persistent About
session settled the producer-side rules per §15.1 (the signal-surface link in the header per §14.3, plus the natural neighbors that
stack), the §15.2 inbox grouping, §15.3 badges and toasts, §15.4 Slice 6 left as API-only and that §19.2 names as candidates:
email categories, §15.5 digest cadence, §15.6 watch/subscription,
§15.7 unread mechanism, §15.8 do-not-disturb, and §15.9 attribution.
Slices 15 left this clean: every user gesture goes through the - **The notification-settings surface** — the actual UI for the
bot wrapper and lands an `actions` row with the underlying actor. preferences/quiet-hours/mute endpoints Slice 6 wired. Topic 13
The producer-side hook is "after a write succeeds, evaluate watches settled the schema and the per-category rules; the surface
and fan-out notification rows." The consumer-side hook is the where a contributor finds the per-category email toggles, the
header badge, the inbox panel, the toast surface, and the per-row digest cadence dropdown, the quiet-hours editor, the watches
read-state machinery. overview, and the per-user mute list is the natural follow-on.
Likely lives at `/settings/notifications` (the link Slice 6's
emails already point at).
- **The admin neighborhood.** §19.2's "Admin surfaces" candidate.
Role management, the §6.2 app-wide write-mute, the audit-log
viewer, the graduation-readiness queue. Topics 12 and 13 both
expanded the admin's repertoire without giving it a centralized
home; Slice 7 picks the framing.
- **Landing page polish.** Slice 1 stood up a minimal landing for
the unauthenticated path; §14 commits a richer shape — what the
framework is, why it exists, what the visitor's first read should
be, and the sign-in affordance.
- **The `/philosophy` route.** [`PHILOSOPHY.md`](../PHILOSOPHY.md)
rendered inline, reachable from the header on every page, so the
reader can return to the framing without leaving the app.
What Slice 6 owns specifically: What Slice 7 does NOT own:
- **The producer fan-out.** Every `actions` row whose event maps to a
§15 signal produces zero-or-more `notifications` rows by joining
against `watches` and applying the §15.1 priority rules. The
fan-out lives as a small module that the bot wrapper invokes
inline after each write — same chokepoint shape Slice 1's
`_log` uses.
- **The §15.2 inbox.** `GET /api/notifications` with the
`unread` / `rfc_slug` / `category` / `bundled` filter chips,
`POST /api/notifications/<id>/read` for per-row marking,
`POST /api/notifications/read` for the bulk filter mark, and the
SSE `GET /api/notifications/stream` that backs the live badge.
- **The §15.3 surface.** The header badge counter (live via the SSE),
the toast on personal-direct events while the user is active, and
the ambient signal — a colored dot per row on the §7 catalog
pointing at watched RFCs with unseen activity.
- **The §15.4 email loop.** Per-category opt-in/out preferences on
the users table (already in the schema), the `/api/users/me/notification-preferences`
endpoints, the email-send adapter that routes a notification's
category through the user's category toggle, and the
`POST /api/webhooks/email-bounce` receiver that sets the global
opt-out. Plus the `GET /api/email/unsubscribe` signed-URL
one-click flow.
- **The §15.5 digest.** A scheduled-job that runs daily and weekly
to roll up unseen notifications into a single email, with the
`notification_digests` table tracking what was included so the
next digest skips what already shipped.
- **The §15.6 watch model.** Auto-watch on first interaction with
an RFC, the per-row state column (`watching` / `following` /
`muted`), the 90-day auto-decay for unset rows, and the explicit
`POST /api/rfcs/<slug>/watch` overrides.
- **The §15.7 unread mechanism.** Advance the `branch_chat_seen`
cursor on every branch read, reconcile inbox notifications to
read when their underlying surface is consumed.
- **The §15.8 do-not-disturb.** Quiet-hours config on the user, the
per-user notification mute list, the orthogonality vs §6.2's
app-wide write-mute.
What Slice 6 does NOT own:
- The §14 chrome polish (still Slice 7).
- The §12 30/90 branch-hygiene timers (still Slice 8). - The §12 30/90 branch-hygiene timers (still Slice 8).
- The §16 deferred items. - 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 The carryovers Slice 7 inherits — the existing §14 spec text, the
(every signal traces back to a row there per §15.9), the SSE §17 endpoint set including Slice 6's settings endpoints, and the
machinery from Slices 2 and 5 (chat-stream and graduate-progress React Router layout already in place.
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 next build session should read `SPEC.md`, `README.md`, 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 without re-briefing. The working agreement in §19.3 continues to
apply: implement the slice, correct the spec only where running apply: implement the slice, correct the spec only where running
code reveals it was wrong at a structural level, accumulate new code reveals it was wrong at a structural level, accumulate new
+79
View File
@@ -1139,3 +1139,82 @@
.branch-dropdown-item.pre-graduation .branch-meta { .branch-dropdown-item.pre-graduation .branch-meta {
font-size: 10px; color: #9ca3af; margin-left: auto; 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; }
+53 -1
View File
@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Routes, Route, Link, useNavigate } from 'react-router-dom' 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 Catalog from './components/Catalog.jsx'
import Inbox from './components/Inbox.jsx'
import RFCView from './components/RFCView.jsx' import RFCView from './components/RFCView.jsx'
import PRView from './components/PRView.jsx' import PRView from './components/PRView.jsx'
import ProposalView from './components/ProposalView.jsx' import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx' import ProposeModal from './components/ProposeModal.jsx'
import Landing from './components/Landing.jsx' import Landing from './components/Landing.jsx'
import ToastHost, { showToast } from './components/ToastHost.jsx'
import './App.css' import './App.css'
export default function App() { export default function App() {
@@ -14,6 +16,9 @@ export default function App() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [proposeOpen, setProposeOpen] = useState(false) const [proposeOpen, setProposeOpen] = useState(false)
const [catalogVersion, setCatalogVersion] = useState(0) const [catalogVersion, setCatalogVersion] = useState(0)
const [inboxOpen, setInboxOpen] = useState(false)
const [unreadCount, setUnreadCount] = useState(0)
const [inboxTick, setInboxTick] = useState(0)
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
@@ -23,6 +28,39 @@ export default function App() {
.finally(() => setLoading(false)) .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) { if (loading) {
return <div className="boot">Loading</div> return <div className="boot">Loading</div>
} }
@@ -38,6 +76,16 @@ export default function App() {
<Link to="/">Wiggleverse RFCs</Link> <Link to="/">Wiggleverse RFCs</Link>
</div> </div>
<div className="header-right"> <div className="header-right">
<button
className="inbox-trigger"
onClick={() => setInboxOpen(o => !o)}
title="Notifications inbox (§15.2)"
>
<span aria-hidden>📮</span>
{unreadCount > 0 && (
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
)}
</button>
<span className="user-name">{me.user.display_name}</span> <span className="user-name">{me.user.display_name}</span>
<span className={`user-role-badge role-${me.user.role}`}>{me.user.role}</span> <span className={`user-role-badge role-${me.user.role}`}>{me.user.role}</span>
<a className="btn-link" href="/auth/logout">Sign out</a> <a className="btn-link" href="/auth/logout">Sign out</a>
@@ -67,6 +115,10 @@ export default function App() {
}} }}
/> />
)} )}
{inboxOpen && (
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
)}
<ToastHost />
</div> </div>
) )
} }
+97
View File
@@ -399,3 +399,100 @@ export async function streamChatTurn(slug, branch, threadId, { text, quote, mode
return { assistantId, userMsgId } 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()
}
+188
View File
@@ -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 (
<div className="inbox-overlay" onClick={onClose}>
<div className="inbox-panel" onClick={e => e.stopPropagation()}>
<header className="inbox-header">
<h2>Inbox</h2>
<button className="btn-link" onClick={onClose}>Close</button>
</header>
<div className="inbox-filters">
<label className="chip">
<input
type="checkbox"
checked={filters.unread}
onChange={e => setFilters(f => ({ ...f, unread: e.target.checked }))}
/>
Unread only
</label>
<select
value={filters.rfcSlug}
onChange={e => setFilters(f => ({ ...f, rfcSlug: e.target.value }))}
className="chip"
>
<option value="">All RFCs</option>
{rfcOptions.map(([slug, title]) => (
<option key={slug} value={slug}>{title}</option>
))}
</select>
<select
value={filters.category}
onChange={e => setFilters(f => ({ ...f, category: e.target.value }))}
className="chip"
>
{CATEGORIES.map(c => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
<label className="chip">
<input
type="checkbox"
checked={filters.bundled}
onChange={e => setFilters(f => ({ ...f, bundled: e.target.checked }))}
/>
Bundle by RFC
</label>
<button
className="btn-link"
onClick={markAllUnderFilter}
disabled={items.every(i => i.read_at)}
>
Mark all read (under filter)
</button>
</div>
<div className="inbox-body">
{loading && <p className="muted">Loading</p>}
{!loading && items.length === 0 && (
<p className="muted">No notifications match. Try a different filter, or come back later.</p>
)}
<ul className="inbox-list">
{items.map(item => (
<InboxRow key={item.id} item={item} onClick={handleRowClick} onClose={onClose} />
))}
</ul>
</div>
</div>
</div>
)
}
function InboxRow({ item, onClick, onClose }) {
const unread = !item.read_at
const target = deepLink(item)
const handle = async () => {
await onClick(item)
if (target) onClose?.()
}
return (
<li className={`inbox-row ${unread ? 'unread' : ''}`}>
<Link to={target || '#'} onClick={handle} className="inbox-row-link">
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
<span className="inbox-summary">{item.summary}</span>
{item.bundled_count > 1 && (
<span className="inbox-bundle-count">+{item.bundled_count - 1}</span>
)}
<span className="inbox-when">{formatWhen(item.created_at)}</span>
</Link>
</li>
)
}
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`
}
+54
View File
@@ -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 (
<div className="toast-host">
{visible.map(t => (
<div
key={t.id}
className={`toast cat-${t.category || 'unknown'}`}
onClick={() => setToasts(prev => prev.filter(x => x.id !== t.id))}
>
{t.summary}
</div>
))}
</div>
)
}