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
+4 -1
View File
@@ -17,7 +17,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import api_branches, api_graduation, api_prs, auth, db, entry as entry_mod, cache
from . import api_branches, api_graduation, api_notifications, api_prs, auth, db, entry as entry_mod, cache
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -55,6 +55,9 @@ def make_router(
router.include_router(api_prs.make_router(config, gitea, bot, providers))
# Slice 5: §13 graduation + §13.1 claim.
router.include_router(api_graduation.make_router(config, gitea, bot))
# Slice 6: §15 notifications surface (inbox, watches, prefs,
# quiet hours, per-user mute, email unsubscribe, bounce webhook).
router.include_router(api_notifications.make_router(config))
# ---------------------------------------------------------------
# Auth surface — extends the prototype's pattern but reads role
+29
View File
@@ -901,6 +901,35 @@ def make_router(
)
return {"ok": True, "message_id": message_id}
@router.post("/api/rfcs/{slug}/branches/{branch}/chat-seen")
async def advance_chat_seen(slug: str, branch: str, body: dict, request: Request) -> dict[str, Any]:
"""§15.7 chat-seen cursor advance.
Body: `{"last_seen_message_id": <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")
async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
+11
View File
@@ -907,6 +907,17 @@ def _audit(
json.dumps(details) if details else None,
),
)
# §15 chokepoint per Slice 6: the bracket rows (graduate_start,
# graduate_complete) drive their own notifications per §15.1.
from . import notify
notify.fan_out_from_action(
actor_user_id=actor_user_id,
action_kind=action_kind,
rfc_slug=rfc_slug,
branch_name=branch_name,
pr_number=pr_number,
details=details,
)
# ---------------------------------------------------------------------------
+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),
)
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.
+13 -1
View File
@@ -25,7 +25,7 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from . import db
from . import db, notify
from .gitea import Gitea
@@ -89,6 +89,18 @@ def _log(
json.dumps(details) if details else None,
),
)
# §15 chokepoint per §19.1 brief: fan-out runs inline after the
# audit row lands. notify.py owns the routing rules and the
# auto-watch upsert per §15.6; the call is intentionally a
# single line here so the chokepoint is one place to read.
notify.fan_out_from_action(
actor_user_id=actor.user_id,
action_kind=action_kind,
rfc_slug=rfc_slug,
branch_name=branch_name,
pr_number=pr_number,
details=details,
)
class Bot:
+40 -1
View File
@@ -139,7 +139,46 @@ def append_user_message(
""",
(thread_id, author_user_id, text, quote),
)
return cur.lastrowid
message_id = cur.lastrowid
# §15 chokepoint per Slice 6: chat messages don't flow through the
# bot wrapper (no Git write), so the fan-out is anchored here. The
# routing is: prior thread authors get personal-direct
# chat_reply_to_my_message; RFC watchers get churn-class
# chat_message_in_participated_thread. The notify module resolves
# the thread's RFC/branch context internally.
_fan_out_chat(thread_id, author_user_id, message_id)
return message_id
def _fan_out_chat(thread_id: int, author_user_id: int, message_id: int) -> None:
from . import notify
row = db.conn().execute(
"SELECT rfc_slug, branch_name, thread_kind FROM threads WHERE id = ?",
(thread_id,),
).fetchone()
if row is None or not row["rfc_slug"]:
return
pr_number = None
if row["thread_kind"] == "review":
pr_row = db.conn().execute(
"""
SELECT pr_number FROM cached_prs
WHERE rfc_slug = ? AND head_branch = ? AND state = 'open'
ORDER BY pr_number DESC LIMIT 1
""",
(row["rfc_slug"], row["branch_name"]),
).fetchone()
if pr_row:
pr_number = pr_row["pr_number"]
notify.fan_out_chat_message(
actor_user_id=author_user_id,
rfc_slug=row["rfc_slug"],
branch_name=row["branch_name"] or "main",
thread_id=thread_id,
message_id=message_id,
is_review_thread=(row["thread_kind"] == "review"),
pr_number=pr_number,
)
def append_assistant_placeholder(*, thread_id: int, model_id: str) -> int:
+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 starlette.middleware.sessions import SessionMiddleware
from . import api as api_routes, auth, cache, db, providers as providers_mod, webhooks
from . import api as api_routes, auth, cache, db, digest, providers as providers_mod, webhooks
from .bot import Bot
from .config import load_config
from .gitea import Gitea
@@ -31,6 +31,7 @@ async def lifespan(app: FastAPI):
gitea = Gitea(config)
bot = Bot(gitea)
reconciler = cache.Reconciler(config, gitea)
digest_sched = digest.DigestScheduler()
# §18 carryover: the multi-provider LLM abstraction. Provider
# construction can fail (missing key, wrong env value) — if it does,
@@ -53,10 +54,12 @@ async def lifespan(app: FastAPI):
app.include_router(webhooks.make_router(config, gitea))
reconciler.start()
digest_sched.start()
log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo)
try:
yield
finally:
await digest_sched.stop()
await reconciler.stop()
await gitea.close()
+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"