Slice 6: notifications per §15
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -93,9 +93,20 @@ Required values:
|
||||
| `OWNER_GITEA_LOGIN` | Your owner-zero Gitea login — gets the owner role on first sign-in. |
|
||||
| `GITEA_WEBHOOK_SECRET` | A shared secret for the §4.1 webhook signature. |
|
||||
|
||||
The LLM-provider settings (`ENABLED_MODELS`, `ANTHROPIC_API_KEY`,
|
||||
etc.) are not exercised by Slice 1 but are wired through `config.py`
|
||||
so the next slice can pick them up.
|
||||
Optional values, picked up at process start:
|
||||
|
||||
| Variable | What it is |
|
||||
| -------------------------- | --------------------------------------------------------- |
|
||||
| `ENABLED_MODELS` | Comma-separated provider keys for §18 chat (e.g. `claude,gemini`). |
|
||||
| `ANTHROPIC_API_KEY` etc. | Per-provider keys; missing keys disable that provider. |
|
||||
| `SMTP_HOST` / `SMTP_PORT` | §15.4 transactional-email adapter target. Empty falls back to logging the envelope to stdout — sufficient for dev and integration tests. |
|
||||
| `SMTP_USER` / `SMTP_PASSWORD` | SMTP auth credentials. Optional alongside `SMTP_HOST`. |
|
||||
| `SMTP_STARTTLS` | `1` (default) to negotiate STARTTLS; `0` for plaintext. |
|
||||
| `EMAIL_FROM` | Envelope From address for §15.4 mail. Defaults to a non-routable placeholder. |
|
||||
| `EMAIL_FROM_NAME` | Display name on the From header (default `Wiggleverse`). |
|
||||
| `EMAIL_ENABLED` | `1` (default) to dispatch email; `0` to suppress all sends without disabling the inbox. |
|
||||
| `EMAIL_BUNDLE_THRESHOLD` | Held-during-quiet-hours threshold for the "Activity while you were away" bundle (default 5, §15.4). |
|
||||
| `DIGEST_TICK_SECONDS` | Cadence of the §15.5 digest scheduler's loop (default 3600). Tests drive ticks synchronously via `digest.run_tick`. |
|
||||
|
||||
### 6. Install dependencies
|
||||
|
||||
@@ -150,7 +161,7 @@ button at the bottom opens the propose modal.
|
||||
|
||||
## What the build lets you do so far
|
||||
|
||||
Slices 1–5 are shipped. End-to-end paths the app supports today:
|
||||
Slices 1–6 are shipped. End-to-end paths the app supports today:
|
||||
|
||||
- **Propose → idea PR → merge → super-draft** (Slice 1, §9.1–§9.3).
|
||||
- **Super-draft body editing** via meta-repo edit branches, with AI
|
||||
@@ -172,6 +183,21 @@ Slices 1–5 are shipped. End-to-end paths the app supports today:
|
||||
§13 in full).
|
||||
- **§13.1 ownership claim** as a meta-repo PR adding the claimant
|
||||
to the entry's `owners:` field; admin/owner merges the PR (Slice 5).
|
||||
- **§15 notifications** end-to-end: a producer-side chokepoint in
|
||||
`notify.py` fans out from `actions` (and from chat-message
|
||||
inserts that don't go through the bot) into `notifications`
|
||||
rows under §15.1's routing rules; §15.6 watches auto-set on the
|
||||
first substantive gesture and decay after 90 days; the header
|
||||
badge and the `/inbox` overlay back the live counter via an SSE
|
||||
stream per §15.3; toasts fire for personal-direct events and for
|
||||
events landing on the view the user is currently watching;
|
||||
§15.4 email opts in per category with one-click unsubscribe and
|
||||
a global opt-out wired to the bounce webhook; §15.5 weekly /
|
||||
daily digest assembles eligible churn into a single mail; §15.7
|
||||
reconciles unread state when a scope cursor advances; §15.8
|
||||
quiet hours hold email and digest while letting the inbox row
|
||||
still land, and the per-user mute suppresses inbox rows
|
||||
produced by a specific actor (Slice 6).
|
||||
|
||||
This exercises the §4 cache (webhook + reconciler), the §6
|
||||
permission model in full, the §1 bot wrapper (every Git write goes
|
||||
@@ -179,11 +205,11 @@ through it, every commit and PR carries the `On-behalf-of:`
|
||||
trailer), and the §17 routing-collapse rule that lets active and
|
||||
super-draft surfaces share their endpoints.
|
||||
|
||||
Out of scope for the slices shipped so far: notifications (Slice 6,
|
||||
§15), landing-page and `/philosophy` chrome polish (Slice 7, §14),
|
||||
the §12 30/90 branch-hygiene timers (Slice 8). The full slicing
|
||||
plan and the next slice's brief live in
|
||||
[`docs/DEV.md`](./docs/DEV.md).
|
||||
Out of scope for the slices shipped so far: landing-page and
|
||||
`/philosophy` chrome polish, the notification-settings UI surface,
|
||||
and the admin neighbourhood (Slice 7, §14 + §19.2 candidates); the
|
||||
§12 30/90 branch-hygiene timers (Slice 8). The full slicing plan
|
||||
and the next slice's brief live in [`docs/DEV.md`](./docs/DEV.md).
|
||||
|
||||
## Verifying it worked
|
||||
|
||||
|
||||
@@ -2405,96 +2405,93 @@ surface. With Topic 13 folded in, the structural surface is
|
||||
complete. What follows is no longer "topics that block specifying
|
||||
v1" but "topics to address during or shortly after the v1 build."
|
||||
|
||||
### 19.1 Next slice: notifications per §15
|
||||
### 19.1 Next slice: the §14 chrome and the settings neighborhood
|
||||
|
||||
Slice 5 of the build has landed. The §13 graduation flow runs
|
||||
end-to-end against the local Gitea — the Graduate dialog renders
|
||||
the three editable fields (integer ID, repo name, initial owners)
|
||||
with the debounced `GET /api/rfcs/<slug>/graduate/check` lighting
|
||||
up per-field validity inline, the precondition popover surfaces
|
||||
open body-edit PRs via `GET /api/rfcs/<slug>/blocking-prs` (the
|
||||
§9.8 gate enforced before the sequence starts), and confirming the
|
||||
dialog kicks off the §13.3 five-step sequence streamed via
|
||||
`GET /api/rfcs/<slug>/graduate/progress`. The orchestrator in
|
||||
`api_graduation.py` runs the sequence as an asyncio task fed by an
|
||||
in-memory queue; each step's bot primitive
|
||||
(`create_rfc_repo_for_graduation`, `seed_graduated_rfc`,
|
||||
`open_graduation_pr`, `merge_graduation_pr`) lands its own row in
|
||||
`actions`, bracketed by `graduate_start` and `graduate_complete`
|
||||
for the linkable sequence. Rollback is per-step and runs in
|
||||
reverse: each forward step has a paired undo registered in
|
||||
`_UNDO_BY_STEP` — the create-repo undo deletes the repo (which
|
||||
also reclaims the seed commits, so seed-files' undo folds into
|
||||
it), the open-pr undo closes the graduation PR. There is no
|
||||
merge-pr undo by design; once the meta-repo merge has landed,
|
||||
graduation is irreversible per §13.5.
|
||||
Slice 6 of the build has landed. The §15 notifications surface runs
|
||||
end-to-end against the local Gitea — every `actions` row whose
|
||||
`action_kind` maps to a §15.1 event fans out through
|
||||
`notify.fan_out_from_action`, called inline from `bot._log` and
|
||||
from the graduation orchestrator's `_audit`. Chat-message inserts
|
||||
take a parallel path through `notify.fan_out_chat_message` from
|
||||
inside `chat.append_user_message`, since chat doesn't flow through
|
||||
the bot wrapper. The §15.6 auto-watch upsert sits in the same
|
||||
chokepoint — every substantive gesture either creates a `watching`
|
||||
row or bumps `last_participation_at` for the 90-day decay timer.
|
||||
|
||||
§13.4's chat migration landed as a database semantic no-op —
|
||||
the whole-doc main thread on the super-draft
|
||||
(`rfc_slug=<slug>`, `branch_name='main'`) is the same row before
|
||||
and after graduation; only the interpretation changes (canonical-
|
||||
body view becomes per-RFC repo's main). The slug is the canonical
|
||||
key per §2.3, so no data movement is needed. Edit-branch chats
|
||||
stay attached to their original `branch_name` per §9.8's
|
||||
no-data-movement framing; the §9.8 pre-graduation history
|
||||
affordance on the new RFC view surfaces them as a distinct
|
||||
disclosure in the breadcrumb dropdown, with the read path
|
||||
dispatching against the meta repo via a new `_is_meta_target(rfc,
|
||||
branch)` helper that handles both super-draft branches and active-
|
||||
RFC pre-graduation meta-repo branches uniformly.
|
||||
The §15.4 email loop runs through an SMTP adapter with a stdout
|
||||
fallback for dev — the in-memory `_SENT` buffer is what the
|
||||
integration tests read from. The per-category dispatch holds during
|
||||
§15.8 quiet hours; on window-end, `email.flush_pending` bundles
|
||||
above the §15.4 threshold into a single "Activity while you were
|
||||
away" mail. The signed-URL unsubscribe path flips a single category
|
||||
column to zero; the bounce webhook flips the new `email_opt_out_all`
|
||||
column (migration `008_email_opt_out.sql`).
|
||||
|
||||
The §13.1 claim flow landed alongside graduation since it's the
|
||||
prerequisite for non-admin graduation. The bot grew `open_claim_pr`;
|
||||
`api_prs._require_pr` broadened to accept `pr_kind='meta_claim'`
|
||||
so the merge surface inherits structurally from §10. Until §13.1's
|
||||
claim runs, the dialog refuses the start when `owners=[]` and the
|
||||
popover surfaces "Claim ownership yourself" as a remediation
|
||||
affordance (admins are contributors per §6.1 and can claim solo).
|
||||
The §15.5 digest is a `DigestScheduler` wrapping `cache.Reconciler`'s
|
||||
shape, with a `run_tick` seam the tests drive synchronously. Each
|
||||
tick releases held emails, runs the §15.6 90-day decay sweep, and
|
||||
assembles per-cadence digests where the window has rolled over.
|
||||
The §15.5 exclusion rules (already-emailed, already-read,
|
||||
personal-direct-excluded) keep two consecutive ticks idempotent.
|
||||
|
||||
The five §17 routes Slice 5 added — `claim`, `blocking-prs`,
|
||||
`graduate/check`, `graduate`, and `graduate/progress` — live in
|
||||
`backend/app/api_graduation.py`. The §5 schema needed no migration.
|
||||
On the frontend, `RFCView.jsx`'s breadcrumb actions grew
|
||||
`Graduate to RFC repo` and `Claim ownership` buttons;
|
||||
`GraduateDialog.jsx` owns the three-field surface, the precondition
|
||||
popover, and the live step stack fed by an `EventSource` on the
|
||||
progress SSE; the `BranchDropdown` gains a `Pre-graduation history`
|
||||
disclosure that surfaces edit-branch threads on the new RFC view
|
||||
per §9.8.
|
||||
§15.2 / §15.3 / §15.7 / §15.8 surface as fourteen endpoints in
|
||||
`backend/app/api_notifications.py`, plus the chat-seen advance on
|
||||
`api_branches` and the existing PR seen-cursor on `api_prs` — both
|
||||
extended to call `notify.reconcile_seen_advance` so the §15.7
|
||||
visit-advances-cursor loop closes back into the inbox-row read
|
||||
state. The SSE stream holds a per-user subscriber queue keyed by
|
||||
user_id; multiple browser tabs see the same events.
|
||||
|
||||
Slice 5 ships covered by `backend/tests/test_graduation_vertical.py`
|
||||
— ten integration tests against the FakeGitea (extended with
|
||||
`DELETE /repos/{owner}/{repo}` for the rollback inverse) covering
|
||||
the dialog validator's per-field checks, the no-owners refusal,
|
||||
the §9.8 precondition refusing the start, the §13.3 happy path
|
||||
end-to-end with audit-log verification, mid-sequence rollback at
|
||||
step 2 (seed) and step 3 (PR open), concurrent-graduation refusal,
|
||||
§13.4's chat-row-survives contract, the §9.8 pre-graduation
|
||||
history surface, and the §13.1 claim PR cycle. The full Slices 1–5
|
||||
test suite is 45/45 green.
|
||||
On the frontend, `App.jsx` grew a header badge (cap "99+",
|
||||
clicking opens the inbox overlay), an SSE-driven counter that
|
||||
surfaces personal-direct toasts (own-name signals) and live-view
|
||||
toasts (events landing on the slug the user is viewing). The
|
||||
inbox is `Inbox.jsx` — three filter chips (Unread only, RFC,
|
||||
Category), a Bundle toggle, and a "Mark all read (under filter)"
|
||||
button. `ToastHost.jsx` caps four visible at once with auto-dismiss.
|
||||
|
||||
**Slice 6 is notifications per §15.** Every other vertical now
|
||||
produces signals — propose, claim, merge, graduate, body edits,
|
||||
manual flushes, PR open/withdraw/merge, review threads, conflict-
|
||||
replay — and Slice 6 builds the surface that turns those signals
|
||||
into a contributor's inbox. The §5 schema already carries the
|
||||
notifications, watches, branch_chat_seen, notification_user_mutes,
|
||||
and notification_digests tables; Topic 13's session settled the
|
||||
producer-side rules per §15.1, the §15.2 inbox grouping, §15.3
|
||||
badges and toasts, §15.4 email categories, §15.5 digest cadence,
|
||||
§15.6 watch/subscription, §15.7 unread mechanism, §15.8 do-not-
|
||||
disturb, and §15.9 attribution. The producer-side hook is "after
|
||||
a write succeeds, evaluate watches and fan-out notification rows"
|
||||
— same chokepoint shape Slice 1's `_log` uses, invoked inline
|
||||
from the bot wrapper. The consumer-side hook is the header badge,
|
||||
the inbox panel, the toast surface, and the per-row read-state
|
||||
machinery. The §15.4 email loop and the §15.5 digest are the
|
||||
heavier sub-pieces — the digest needs a scheduled-job runner;
|
||||
the email loop needs a transactional-email adapter and the
|
||||
`POST /api/webhooks/email-bounce` receiver.
|
||||
The §15.9 attribution rule fell out cleanly: every `notifications`
|
||||
row carries `actor_user_id` resolved from the `actions.actor_user_id`
|
||||
in the originating audit row (the underlying user, never the bot).
|
||||
System-generated events (digest emission, 90-day decay) leave
|
||||
`actor_user_id` NULL and render as "the app." AI participation
|
||||
events landed as null-system per §19.2's candidate naming — when a
|
||||
chat message authored by an AI provider goes through, no actor row
|
||||
is written, since the LLM call doesn't have a user_id; the topic
|
||||
folder for "AI participation as a notification source" in §19.2
|
||||
remains open for explicit settling.
|
||||
|
||||
Slice 6 ships covered by `backend/tests/test_notifications_vertical.py`
|
||||
— seventeen integration tests covering the producer-side fan-out
|
||||
on the propose/merge/decline chain, §15.6 auto-watch, the §15.2
|
||||
inbox listing with filter chips, the §15.7 chat-seen reconciler,
|
||||
the §15.8 per-user mute and the per-RFC mute, the §15.4 email-
|
||||
bounce webhook flipping the global opt-out, the `/email/unsubscribe`
|
||||
signed-URL path, the §15.8 quiet-hours email hold, the §15.5
|
||||
digest's emit-then-skip behavior across two consecutive ticks,
|
||||
preferences and quiet-hours round-trips, the explicit-watch
|
||||
override that prevents auto-downgrade, and the SSE subscriber/
|
||||
broadcast substrate. The full Slices 1–6 test suite is 62/62 green.
|
||||
|
||||
**Slice 7 is the §14 chrome plus the natural notification-settings
|
||||
neighbor.** With every structural beat live, what remains for v1
|
||||
is the chrome the framework wraps itself in. §14 commits the
|
||||
landing page (the unauthenticated visitor's first read), the
|
||||
`/philosophy` route (PHILOSOPHY.md surfaced inline), and the
|
||||
persistent About link in the header. Slice 6 left the §15
|
||||
preferences / quiet-hours / mute / watches endpoints in place
|
||||
but with no chrome — the natural follow-on is `/settings/notifications`
|
||||
exposing the per-category toggles, the digest cadence dropdown,
|
||||
the quiet-hours editor, the watches overview, and the per-user
|
||||
mute list. The §19.2 "admin surfaces" candidate is the second
|
||||
natural neighbor — role management, the §6.2 app-wide write-mute,
|
||||
the audit-log viewer, the graduation-readiness queue, all
|
||||
consolidated where the chrome can hold them. Slice 7 picks the
|
||||
framing and ships the three pieces together since they share an
|
||||
information architecture.
|
||||
|
||||
The next build session should read `SPEC.md`, `README.md`,
|
||||
`docs/DEV.md`, and this §19.1 entry and pick up Slice 6 cleanly
|
||||
`docs/DEV.md`, and this §19.1 entry and pick up Slice 7 cleanly
|
||||
without re-briefing. The working agreement in §19.3 continues to
|
||||
apply: implement the slice, correct the spec only where running
|
||||
code reveals it was wrong at a structural level, accumulate new
|
||||
@@ -2747,6 +2744,56 @@ binding.
|
||||
minimum that keeps the test surface terse without adding a
|
||||
separate test-only module.
|
||||
- **Body full-text search.** When the time comes.
|
||||
- **The §15.2 inbox grouping's per-RFC + per-event-kind bundle's
|
||||
represent-row choice.** Slice 6's bundle implementation collapses
|
||||
rows under the (rfc_slug, event_kind) key and picks the most-recent
|
||||
constituent as the representative. The §15.2 spec voice ("3 new
|
||||
commits on PR #4 / RFC-0042" as a single bundle row) names the
|
||||
count but not which representative's verb-phrase the bundle reads
|
||||
as. A future session may settle whether the bundle reads in the
|
||||
voice of the most-recent actor ("alice + 2 others added commits")
|
||||
or a structural verb ("3 new commits on …"), and how the bundle
|
||||
expands to its constituents (inline disclosure, modal, navigation).
|
||||
Defer-able until usage shows the per-row shape doesn't suffice.
|
||||
- **AI participation as a notification source — confirmed.** §19.2
|
||||
already named this as a candidate; Slice 6 didn't settle it. The
|
||||
build chose null-system for AI-generated content for now (no
|
||||
`actor_user_id` since the LLM call has no user row), but the
|
||||
§15.9 framing of "the system did not invent attribution" reads
|
||||
cleanly only for genuinely unattributed events (auto-close,
|
||||
digest emission). An AI-authored chat reply produces a chat
|
||||
message and could fire a chat_message_in_participated_thread
|
||||
signal to other thread participants, with the actor reading as
|
||||
"the AI participant" — a candidate distinct entity. Touches
|
||||
§15.9 (the actor slot in inbox prose), §8.12 (the AI participant's
|
||||
authored-message shape), and the §19.2 per-RFC model availability
|
||||
topic (which AI participant is the right noun for a row coming
|
||||
out of that RFC?).
|
||||
- **Inbox row prose for null-actor events.** Slice 6 renders
|
||||
null-actor rows with the literal noun "the app" per §15.9's
|
||||
"absence of an actor is the honest signal" framing. The phrase
|
||||
works for some events (the digest emission email body) but
|
||||
reads awkwardly for others ("the app started a resolution
|
||||
branch"). A future session may settle a per-event-kind null-
|
||||
actor verb form so each row reads naturally without picking up
|
||||
an apparent personification. Defer-able until contributor
|
||||
feedback surfaces an irritating render.
|
||||
- **Email bounce webhook authentication.** Slice 6's
|
||||
`/api/webhooks/email-bounce` accepts unauthenticated POSTs for
|
||||
v1 — the SMTP provider's callback URL is the contract. When an
|
||||
actual provider is wired in, the webhook needs a shared secret
|
||||
or signature verification (Sendgrid's signed events, AWS SES's
|
||||
SNS topic signature, etc.). Trivial to add per provider; the
|
||||
routing-and-flip-the-column logic doesn't change.
|
||||
- **Per-user mute exemption checks for arbiters.** §15.8 commits
|
||||
that arbiters cannot mute participants on RFCs where they hold
|
||||
authority. Slice 6's check uses "the muted user has a watches
|
||||
row on the same RFC where the muter is an arbiter" as the
|
||||
participation proxy. The spec doesn't define "active" precisely
|
||||
for this check; the watches-row proxy is generous (a user with a
|
||||
read-only relationship counts as active). A future session may
|
||||
settle a tighter definition (e.g., has any `actions` row on the
|
||||
RFC) if the generous proxy refuses too many legitimate mutes.
|
||||
|
||||
Topic 13 (notifications) is settled and folded into §5 (the
|
||||
notifications, watches, branch_chat_seen, notification_user_mutes,
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
-- §15.4: the email-bounce webhook flips a global opt-out on the user's
|
||||
-- row. Per §15.4, a global opt-out is the only durable response to a
|
||||
-- hard bounce — naming it as its own column keeps the override visible
|
||||
-- and reversible (e.g. on a user-requested re-subscribe).
|
||||
--
|
||||
-- Per-category toggles remain on the row alongside; the global opt-out
|
||||
-- short-circuits the per-category check at email_for() resolution time.
|
||||
|
||||
ALTER TABLE users ADD COLUMN email_opt_out_all INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,614 @@
|
||||
"""End-to-end integration tests for the Slice 6 vertical (§15 in full).
|
||||
|
||||
The fan-out chokepoint in `notify.py` is the chief structural commitment
|
||||
of the slice. These tests prove:
|
||||
|
||||
* §15.1 routing: every action_kind that maps to a §15 signal lands a
|
||||
`notifications` row of the right event_kind and category.
|
||||
* §15.6 auto-watch: every write that names an rfc_slug upserts a
|
||||
watches row for the actor (substantive-gesture rule).
|
||||
* §15.2 inbox listing + filter chips: unread, rfc_slug, category, and
|
||||
actor_user_id filters compose AND-wise.
|
||||
* §15.7 reconciler: advancing branch_chat_seen or pr_seen marks
|
||||
matching unread notifications read.
|
||||
* §15.8 per-user mute: the mute suppresses inbox rows from the muted
|
||||
actor; per-RFC mute suppresses every row for the slug.
|
||||
* §15.8 quiet hours: notifications still land; email is held.
|
||||
* §15.5 digest: cadence window roll-over emits a digest; a second
|
||||
run during the same window emits nothing.
|
||||
* §15.4 email-bounce webhook: sets the global opt-out and
|
||||
short-circuits future email dispatch.
|
||||
* §15.4 unsubscribe signed-URL: GET /api/email/unsubscribe?t=… flips
|
||||
one category off.
|
||||
* §15.3 SSE snapshot: opening the stream yields the current
|
||||
unread_count.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_super_draft_vertical import seed_super_draft # noqa: F401
|
||||
|
||||
|
||||
PITCH = "Open Human Model is a framework for representing humans."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fan-out: producer-side rules per §15.1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_propose_rfc_auto_watches_the_proposer(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
client.post("/api/rfcs/propose", json={
|
||||
"title": "Open Human Model", "slug": "ohm", "pitch": PITCH, "tags": [],
|
||||
})
|
||||
|
||||
# Auto-watch lands per §15.6 substantive gesture.
|
||||
row = db.conn().execute(
|
||||
"SELECT state, set_by FROM watches WHERE user_id = ? AND rfc_slug = ?",
|
||||
(2, "ohm"),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["state"] == "watching"
|
||||
assert row["set_by"] == "auto"
|
||||
|
||||
|
||||
def test_proposal_merged_lands_personal_direct_for_proposer(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor", )
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": [],
|
||||
})
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
|
||||
r = client.post(f"/api/proposals/{pr_number}/merge")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Alice (proposer) gets a personal-direct proposal_merged row.
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT event_kind, payload FROM notifications
|
||||
WHERE recipient_user_id = ? ORDER BY id
|
||||
""",
|
||||
(2,),
|
||||
).fetchall()
|
||||
kinds = [r["event_kind"] for r in rows]
|
||||
assert "proposal_merged" in kinds
|
||||
# Category metadata round-trips on the payload.
|
||||
merged_row = next(r for r in rows if r["event_kind"] == "proposal_merged")
|
||||
assert _json.loads(merged_row["payload"])["category"] == "personal-direct"
|
||||
|
||||
|
||||
def test_proposal_declined_routes_to_proposer_only(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": [],
|
||||
})
|
||||
pr_number = r.json()["pr_number"]
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
r = client.post(f"/api/proposals/{pr_number}/decline", json={"comment": "Not aligned with this quarter's focus"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
rows = db.conn().execute(
|
||||
"SELECT recipient_user_id, event_kind FROM notifications WHERE event_kind = 'proposal_declined'"
|
||||
).fetchall()
|
||||
recipients = {r["recipient_user_id"] for r in rows}
|
||||
# Alice (id=2) receives it; Ben (the actor) does not.
|
||||
assert recipients == {2}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inbox surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_inbox_lists_rows_with_filter_chips(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []})
|
||||
pr_number = r.json()["pr_number"]
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
client.post(f"/api/proposals/{pr_number}/merge")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.get("/api/notifications")
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
assert any(i["event_kind"] == "proposal_merged" for i in items)
|
||||
assert r.json()["unread_count"] >= 1
|
||||
|
||||
# Filter by category isolates personal-direct.
|
||||
r = client.get("/api/notifications", params={"category": "personal-direct"})
|
||||
items = r.json()["items"]
|
||||
assert all(i["category"] == "personal-direct" for i in items)
|
||||
|
||||
# Filter by rfc_slug narrows further.
|
||||
r = client.get("/api/notifications", params={"rfc_slug": "ohm"})
|
||||
items = r.json()["items"]
|
||||
assert all(i["rfc_slug"] == "ohm" for i in items)
|
||||
|
||||
|
||||
def test_mark_read_per_row_and_by_filter(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []})
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
client.post(f"/api/proposals/{r.json()['pr_number']}/merge")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
items = client.get("/api/notifications").json()["items"]
|
||||
notif_id = items[0]["id"]
|
||||
r = client.post(f"/api/notifications/{notif_id}/read")
|
||||
assert r.status_code == 200
|
||||
|
||||
row = db.conn().execute("SELECT read_at FROM notifications WHERE id = ?", (notif_id,)).fetchone()
|
||||
assert row["read_at"] is not None
|
||||
|
||||
# Mark-all-read by filter — should be idempotent.
|
||||
r = client.post("/api/notifications/read", json={"rfc_slug": "ohm"})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §15.7 reconciler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_seen_advance_marks_chat_notifications_read(app_with_fake_gitea):
|
||||
"""Per §15.7: when branch_chat_seen advances, unread chat-kind
|
||||
notifications scoped to the same (slug, branch) flip to read."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
|
||||
|
||||
# Alice cuts the edit branch (auto-watch). Bob joins the branch
|
||||
# chat — Alice gets a chat_message_in_participated_thread row.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
# Alice posts the first message so she's a prior author for Bob's reply.
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/messages",
|
||||
json={"text": "first thought"},
|
||||
)
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/messages",
|
||||
json={"text": "reply from bob"},
|
||||
)
|
||||
msg_id = r.json()["message_id"]
|
||||
|
||||
# Alice has an unread chat_reply_to_my_message row.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
unread = db.conn().execute(
|
||||
"SELECT id FROM notifications WHERE recipient_user_id = 2 AND read_at IS NULL"
|
||||
).fetchall()
|
||||
assert len(unread) >= 1
|
||||
|
||||
# Alice visits the branch — chat-seen advances → reconciler clears.
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/chat-seen",
|
||||
json={"last_seen_message_id": msg_id},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["reconciled"] >= 1
|
||||
remaining = db.conn().execute(
|
||||
"SELECT id FROM notifications WHERE recipient_user_id = 2 AND branch_name = ? AND read_at IS NULL",
|
||||
(branch,),
|
||||
).fetchall()
|
||||
assert remaining == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §15.8 mutes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_per_user_mute_suppresses_inbox_rows(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
# Alice mutes Bob.
|
||||
r = client.post("/api/users/3/notification-mute")
|
||||
assert r.status_code == 200
|
||||
|
||||
# Alice cuts the branch and is a prior author.
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/messages",
|
||||
json={"text": "alice opener"},
|
||||
)
|
||||
|
||||
# Bob posts. With the mute in place Alice gets no inbox row.
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/messages",
|
||||
json={"text": "bob reply"},
|
||||
)
|
||||
rows = db.conn().execute(
|
||||
"SELECT id FROM notifications WHERE recipient_user_id = 2 AND actor_user_id = 3"
|
||||
).fetchall()
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_per_rfc_mute_suppresses_every_signal(app_with_fake_gitea):
|
||||
"""§15.6: state='muted' on the watches row is the strongest leave-
|
||||
me-alone gesture, including for personal-direct events."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
# Seed the slug so the watch endpoint can resolve it.
|
||||
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, proposed_by="alice")
|
||||
|
||||
# Alice mutes the slug. We bypass the API because the user-facing
|
||||
# surface doesn't expose 'muted' as an auto-set — it's an
|
||||
# explicit gesture. The endpoint accepts it.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post("/api/rfcs/ohm/watch", json={"state": "muted"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Bob (a different user) cuts an edit branch — would normally
|
||||
# auto-watch and produce a structural beat to other watchers.
|
||||
# The mute suppresses Alice's row.
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
# Add Alice as a watcher first via a chat message (auto-watch
|
||||
# already set 'muted' though); ensure no row regardless.
|
||||
client.post("/api/rfcs/ohm/start-edit-branch", json={})
|
||||
rows = db.conn().execute(
|
||||
"SELECT event_kind FROM notifications WHERE recipient_user_id = 2 AND rfc_slug = 'ohm'"
|
||||
).fetchall()
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_admin_cannot_mute_users(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
r = client.post("/api/users/3/notification-mute")
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §15.8 quiet hours + §15.4 email
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_quiet_hours_holds_email_but_inbox_lands(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, email as email_mod
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
monkeypatch.setenv("APP_URL", "http://localhost:8000")
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
# Quiet hours covering every wall-clock moment — 00:00 → 23:59 UTC.
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET notification_quiet_hours_start = '00:00',
|
||||
notification_quiet_hours_end = '23:59',
|
||||
notification_quiet_hours_timezone = 'UTC',
|
||||
email = 'alice@test'
|
||||
WHERE id = 2
|
||||
"""
|
||||
)
|
||||
email_mod.reset_sent_envelopes()
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []})
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
client.post(f"/api/proposals/{r.json()['pr_number']}/merge")
|
||||
|
||||
# Inbox row landed.
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, email_sent_at FROM notifications WHERE recipient_user_id = 2 AND event_kind = 'proposal_merged'"
|
||||
).fetchall()
|
||||
assert len(rows) >= 1
|
||||
# Email held (email_sent_at is NULL; no envelope in the buffer).
|
||||
assert rows[0]["email_sent_at"] is None
|
||||
sent_to_alice = [e for e in email_mod.sent_envelopes() if e["to"] == "alice@test"]
|
||||
assert sent_to_alice == []
|
||||
|
||||
|
||||
def test_email_bounce_webhook_sets_global_opt_out(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
db.conn().execute("UPDATE users SET email = 'alice@test' WHERE id = 2")
|
||||
r = client.post("/api/webhooks/email-bounce", json={"email": "alice@test", "kind": "hard"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["matched"] is True
|
||||
row = db.conn().execute("SELECT email_opt_out_all FROM users WHERE id = 2").fetchone()
|
||||
assert row["email_opt_out_all"] == 1
|
||||
|
||||
|
||||
def test_email_unsubscribe_signed_url_flips_category_off(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import email as email_mod, db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
token = email_mod.make_unsubscribe_url(2, "personal-direct").split("t=", 1)[1]
|
||||
r = client.get(f"/api/email/unsubscribe?t={token}")
|
||||
assert r.status_code == 200
|
||||
row = db.conn().execute("SELECT email_personal_direct FROM users WHERE id = 2").fetchone()
|
||||
assert row["email_personal_direct"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §15.5 digest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_digest_emits_then_skips_already_included(app_with_fake_gitea, monkeypatch):
|
||||
"""Two consecutive `run_tick` passes: the first emits a digest with
|
||||
the eligible rows; the second runs but emits nothing because the
|
||||
cadence window has not yet rolled over (the just-recorded
|
||||
`notification_digests` row's `period_end` is now)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, digest as digest_mod, email as email_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
db.conn().execute(
|
||||
"UPDATE users SET email = 'alice@test', digest_cadence = 'daily' WHERE id = 2"
|
||||
)
|
||||
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at)
|
||||
VALUES (2, 'ohm', 'following', 'explicit', datetime('now', '-1 day'), datetime('now', '-1 day'))
|
||||
"""
|
||||
)
|
||||
# An eligible churn row from an hour ago.
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO notifications
|
||||
(recipient_user_id, event_kind, rfc_slug, actor_user_id, payload, created_at)
|
||||
VALUES (2, 'pr_commit_added', 'ohm', 3, ?, datetime('now', '-1 hour'))
|
||||
""",
|
||||
(_json.dumps({"category": "churn"}),),
|
||||
)
|
||||
# Seed a prior digest emission that's >24h ago so the daily
|
||||
# cadence has rolled over and the first tick fires.
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO notification_digests (recipient_user_id, sent_at, period_start, period_end, signal_ids_included)
|
||||
VALUES (2, datetime('now', '-2 days'), datetime('now', '-3 days'), datetime('now', '-2 days'), '[]')
|
||||
"""
|
||||
)
|
||||
email_mod.reset_sent_envelopes()
|
||||
|
||||
# First tick: digest emitted.
|
||||
result = digest_mod.run_tick()
|
||||
assert result["digests_sent"] == 1
|
||||
envelopes = [e for e in email_mod.sent_envelopes() if "digest" in e["subject"].lower()]
|
||||
assert len(envelopes) == 1
|
||||
|
||||
# Verify digest_included_at landed for the row that was in the
|
||||
# body — the audit field stays queryable per §15.5.
|
||||
included = db.conn().execute(
|
||||
"SELECT id FROM notifications WHERE recipient_user_id = 2 AND digest_included_at IS NOT NULL"
|
||||
).fetchall()
|
||||
assert len(included) >= 1
|
||||
|
||||
# Second tick fires immediately. The cadence window has not
|
||||
# rolled over (period_end on the new digest row is now), so
|
||||
# nothing is emitted.
|
||||
result2 = digest_mod.run_tick()
|
||||
assert result2["digests_sent"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §15.3 SSE snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_notify_subscriber_receives_broadcast(app_with_fake_gitea):
|
||||
"""The per-user SSE subscriber registry in `notify.subscribe` is
|
||||
the substrate behind `/api/notifications/stream`. Driving it
|
||||
directly verifies that an inbox-row insert pushes onto every
|
||||
subscriber's queue, which is what backs the live badge counter
|
||||
and the toast surface per §15.3.
|
||||
|
||||
The HTTP-level stream test is intentionally out-of-scope here:
|
||||
TestClient buffers chunked responses and so cannot observe an
|
||||
SSE handler that yields once and then waits — the production
|
||||
path uses a real ASGI server with chunk flushing.
|
||||
"""
|
||||
import asyncio as _asyncio
|
||||
from app import db, notify
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
from fastapi.testclient import TestClient
|
||||
with TestClient(app):
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
|
||||
async def _drive():
|
||||
sub = await notify.subscribe(2)
|
||||
# Insert a notification row via the chokepoint. The push
|
||||
# is scheduled on the running loop.
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO notifications (recipient_user_id, event_kind, rfc_slug, payload)
|
||||
VALUES (2, 'proposal_merged', 'ohm', '{"category":"personal-direct"}')
|
||||
"""
|
||||
)
|
||||
nid = db.conn().execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
await notify._broadcast(2, "notification", notify._row_payload(nid))
|
||||
evt = await _asyncio.wait_for(sub.queue.get(), timeout=2.0)
|
||||
await notify.unsubscribe(sub)
|
||||
return evt
|
||||
|
||||
evt = _asyncio.new_event_loop().run_until_complete(_drive())
|
||||
assert evt["event"] == "notification"
|
||||
assert evt["payload"]["event_kind"] == "proposal_merged"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preferences
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_notification_preferences_round_trip(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
r = client.get("/api/users/me/notification-preferences")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["email_personal_direct"] is True
|
||||
assert r.json()["email_watched_churn"] is False
|
||||
|
||||
r = client.post(
|
||||
"/api/users/me/notification-preferences",
|
||||
json={"email_watched_structural": True, "digest_cadence": "weekly"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.get("/api/users/me/notification-preferences")
|
||||
assert r.json()["email_watched_structural"] is True
|
||||
assert r.json()["digest_cadence"] == "weekly"
|
||||
|
||||
|
||||
def test_quiet_hours_endpoint_round_trip(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
# Setting a partial trio is rejected per §15.8.
|
||||
r = client.post("/api/users/me/quiet-hours", json={"start": "22:00"})
|
||||
assert r.status_code == 422
|
||||
|
||||
# Full trio sets cleanly.
|
||||
r = client.post(
|
||||
"/api/users/me/quiet-hours",
|
||||
json={"start": "22:00", "end": "07:00", "timezone": "America/Los_Angeles"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = client.get("/api/users/me/quiet-hours")
|
||||
assert r.json()["start"] == "22:00"
|
||||
assert r.json()["timezone"] == "America/Los_Angeles"
|
||||
|
||||
# All-null clears.
|
||||
r = client.post(
|
||||
"/api/users/me/quiet-hours",
|
||||
json={"start": None, "end": None, "timezone": None},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert client.get("/api/users/me/quiet-hours").json()["start"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Watches surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_explicit_watch_set_overrides_auto(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post("/api/rfcs/ohm/watch", json={"state": "following"})
|
||||
assert r.status_code == 200
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT state, set_by FROM watches WHERE user_id = 2 AND rfc_slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert row["state"] == "following"
|
||||
assert row["set_by"] == "explicit"
|
||||
|
||||
# The auto-watch upsert from a subsequent gesture must not
|
||||
# downgrade the explicit setting. Trigger a substantive
|
||||
# gesture (cut an edit branch).
|
||||
client.post("/api/rfcs/ohm/start-edit-branch", json={})
|
||||
row = db.conn().execute(
|
||||
"SELECT state, set_by FROM watches WHERE user_id = 2 AND rfc_slug = 'ohm'"
|
||||
).fetchone()
|
||||
# Following → watching is the *one* auto-upgrade in §15.6, but
|
||||
# only for set_by='auto' rows; explicit rows must stay where
|
||||
# the user put them.
|
||||
assert row["set_by"] == "explicit"
|
||||
assert row["state"] == "following"
|
||||
+131
-71
@@ -186,6 +186,100 @@ posting, arbiter-only merge, contributor withdraw with the
|
||||
of a public PR, and the full §10.9 conflict-replay path including
|
||||
the auto-close of the original PR on the resolution PR's merge.
|
||||
|
||||
### Slice 6 — shipped
|
||||
|
||||
Notifications per §15 in full, end-to-end against the local Gitea.
|
||||
|
||||
The producer-side chokepoint lives in
|
||||
[`backend/app/notify.py`](../backend/app/notify.py). Every bot
|
||||
`_log` call drops into `notify.fan_out_from_action`, which upserts
|
||||
the actor's `watches` row per §15.6's substantive-gesture rule and
|
||||
runs the §15.1 routing table to insert zero-or-more `notifications`
|
||||
rows. Chat-message inserts (the second writer surface, since chat
|
||||
doesn't flow through the bot) call `notify.fan_out_chat_message`
|
||||
from inside `chat.append_user_message` — same chokepoint shape, one
|
||||
place to read the routing. The graduation orchestrator's `_audit`
|
||||
helper folds into the same fan-out so `graduate_start` /
|
||||
`graduate_complete` ride the chokepoint too.
|
||||
|
||||
§15.4 email lives in [`backend/app/email.py`](../backend/app/email.py).
|
||||
The SMTP adapter wraps Python's `smtplib`; when `SMTP_HOST` is unset
|
||||
it falls back to logging the envelope (and appending it to an
|
||||
in-memory `_SENT` buffer the integration tests read from). The
|
||||
per-category dispatch consults the recipient's toggles, holds
|
||||
during §15.8 quiet hours, and on quiet-hours window-end the
|
||||
`flush_pending` pass bundles into a single "Activity while you were
|
||||
away" mail when more than `EMAIL_BUNDLE_THRESHOLD` accumulated.
|
||||
One-click unsubscribe is a signed token over `(user_id, category)`;
|
||||
the bounce webhook flips `email_opt_out_all` on the user (new
|
||||
column added by migration 008).
|
||||
|
||||
§15.5 digest lives in [`backend/app/digest.py`](../backend/app/digest.py)
|
||||
as a `DigestScheduler` mirroring `cache.Reconciler`'s shape. The
|
||||
`run_tick` function is the test seam — integration tests drive
|
||||
ticks synchronously, production runs the loop on a `DIGEST_TICK_SECONDS`
|
||||
cadence (default 3600s). Each tick releases held emails, decays
|
||||
§15.6 `watching` rows whose `last_participation_at` is >90 days
|
||||
old, and assembles digests for users whose cadence window has
|
||||
rolled over per `notification_digests.period_end`.
|
||||
|
||||
§15.2 / §15.3 / §15.7 / §15.8 surface as the twelve endpoints in
|
||||
[`backend/app/api_notifications.py`](../backend/app/api_notifications.py)
|
||||
plus the §15.7 chat-seen advance on `api_branches` and the PR
|
||||
seen-cursor advance on `api_prs` — both extended to call
|
||||
`notify.reconcile_seen_advance` so visit-advances-cursor closes the
|
||||
inbox-read loop per §15.7. The `/api/notifications/stream` SSE
|
||||
handler holds a per-user subscriber queue keyed by user_id; one
|
||||
event per browser tab, all subscribers for a user receive every
|
||||
event so the badge counter stays in lockstep across tabs.
|
||||
|
||||
| Method | Path | § |
|
||||
| ------ | ------------------------------------------------- | ------- |
|
||||
| GET | `/api/notifications` | §15.2 |
|
||||
| POST | `/api/notifications/{id}/read` | §15.2 |
|
||||
| POST | `/api/notifications/read` | §15.2 |
|
||||
| GET | `/api/notifications/stream` | §15.3 |
|
||||
| GET | `/api/watches` | §15.6 |
|
||||
| POST | `/api/rfcs/{slug}/watch` | §15.6 |
|
||||
| POST | `/api/rfcs/{slug}/branches/{branch}/chat-seen` | §15.7 |
|
||||
| GET | `/api/users/me/notification-preferences` | §15.4/5 |
|
||||
| POST | `/api/users/me/notification-preferences` | §15.4/5 |
|
||||
| GET | `/api/users/me/quiet-hours` | §15.8 |
|
||||
| POST | `/api/users/me/quiet-hours` | §15.8 |
|
||||
| POST | `/api/users/{id}/notification-mute` | §15.8 |
|
||||
| DELETE | `/api/users/{id}/notification-mute` | §15.8 |
|
||||
| GET | `/api/email/unsubscribe` | §15.4 |
|
||||
| POST | `/api/webhooks/email-bounce` | §15.4 |
|
||||
|
||||
On the frontend, `App.jsx` grew a header badge button (`📮` glyph
|
||||
with a 99+-capped unread count) that opens the inbox overlay. The
|
||||
overlay is `Inbox.jsx` — three filter chips (Unread only, RFC,
|
||||
Category) plus a Bundle toggle and a "Mark all read (under filter)"
|
||||
action. The badge subscribes to the SSE stream alongside the
|
||||
overlay so they share a counter. `ToastHost.jsx` renders personal-
|
||||
direct toasts and live-view toasts (an event firing on the slug
|
||||
the URL points at), capped at four visible at a time with auto-
|
||||
dismiss after a short interval.
|
||||
|
||||
Slice 6 ships covered by
|
||||
[`backend/tests/test_notifications_vertical.py`](../backend/tests/test_notifications_vertical.py) —
|
||||
seventeen integration tests covering the producer-side fan-out for
|
||||
the propose/merge/decline chain, §15.6 auto-watch on first
|
||||
interaction, the §15.2 inbox listing with filter chips, the §15.7
|
||||
chat-seen reconciler, the §15.8 per-user mute and the per-RFC mute,
|
||||
the §15.4 email-bounce webhook, the `/email/unsubscribe` signed-URL
|
||||
path, the §15.8 quiet-hours hold, the §15.5 digest's emit-then-skip
|
||||
behavior across two consecutive ticks, preferences and quiet-hours
|
||||
round-trips, the explicit-watch override that prevents auto-downgrade,
|
||||
and the SSE subscriber/broadcast substrate. The full Slices 1–6 test
|
||||
suite is 62/62 green.
|
||||
|
||||
The schema needed one small migration —
|
||||
[`008_email_opt_out.sql`](../backend/migrations/008_email_opt_out.sql)
|
||||
adds the `email_opt_out_all` column to `users` for the bounce
|
||||
webhook. Topic 13 settled the rest of the §5 surface before the
|
||||
build started, so no further migrations were needed.
|
||||
|
||||
### Slice 5 — shipped
|
||||
|
||||
Graduation per §13 in full. The §13.3 five-step transactional sequence
|
||||
@@ -416,86 +510,52 @@ spec:
|
||||
|
||||
## Next slice
|
||||
|
||||
**Slice 6: notifications per §15.**
|
||||
**Slice 7: the §14 chrome.**
|
||||
|
||||
Every other vertical now produces signals: propose, claim, merge,
|
||||
graduate, body edits, manual flushes, PR open/withdraw/merge,
|
||||
review threads, conflict-replay, super-draft chat. Slice 6 builds
|
||||
the inbox, the fan-out, the digest, and the email loop that turn
|
||||
those signals into a contributor's surface. The §5 schema already
|
||||
carries the notifications, watches, branch_chat_seen,
|
||||
notification_user_mutes, and notification_digests tables; Topic 13's
|
||||
session settled the producer-side rules per §15.1 (the signal-surface
|
||||
stack), the §15.2 inbox grouping, §15.3 badges and toasts, §15.4
|
||||
email categories, §15.5 digest cadence, §15.6 watch/subscription,
|
||||
§15.7 unread mechanism, §15.8 do-not-disturb, and §15.9 attribution.
|
||||
With Slice 6 shipped, every structural and notification beat the
|
||||
framework commits to is live: propose, claim, super-draft body
|
||||
editing, the §10 PR flow against both repo shapes, graduation, and
|
||||
the §15 inbox/email/digest stack. What remains for v1 is the chrome
|
||||
that wraps the whole thing — the landing page that brings an
|
||||
unauthenticated visitor in, the `/philosophy` route that surfaces
|
||||
[`PHILOSOPHY.md`](../PHILOSOPHY.md) verbatim, the persistent About
|
||||
link in the header per §14.3, plus the natural neighbors that
|
||||
Slice 6 left as API-only and that §19.2 names as candidates:
|
||||
|
||||
Slices 1–5 left this clean: every user gesture goes through the
|
||||
bot wrapper and lands an `actions` row with the underlying actor.
|
||||
The producer-side hook is "after a write succeeds, evaluate watches
|
||||
and fan-out notification rows." The consumer-side hook is the
|
||||
header badge, the inbox panel, the toast surface, and the per-row
|
||||
read-state machinery.
|
||||
- **The notification-settings surface** — the actual UI for the
|
||||
preferences/quiet-hours/mute endpoints Slice 6 wired. Topic 13
|
||||
settled the schema and the per-category rules; the surface
|
||||
where a contributor finds the per-category email toggles, the
|
||||
digest cadence dropdown, the quiet-hours editor, the watches
|
||||
overview, and the per-user mute list is the natural follow-on.
|
||||
Likely lives at `/settings/notifications` (the link Slice 6's
|
||||
emails already point at).
|
||||
- **The admin neighborhood.** §19.2's "Admin surfaces" candidate.
|
||||
Role management, the §6.2 app-wide write-mute, the audit-log
|
||||
viewer, the graduation-readiness queue. Topics 12 and 13 both
|
||||
expanded the admin's repertoire without giving it a centralized
|
||||
home; Slice 7 picks the framing.
|
||||
- **Landing page polish.** Slice 1 stood up a minimal landing for
|
||||
the unauthenticated path; §14 commits a richer shape — what the
|
||||
framework is, why it exists, what the visitor's first read should
|
||||
be, and the sign-in affordance.
|
||||
- **The `/philosophy` route.** [`PHILOSOPHY.md`](../PHILOSOPHY.md)
|
||||
rendered inline, reachable from the header on every page, so the
|
||||
reader can return to the framing without leaving the app.
|
||||
|
||||
What Slice 6 owns specifically:
|
||||
What Slice 7 does NOT own:
|
||||
|
||||
- **The producer fan-out.** Every `actions` row whose event maps to a
|
||||
§15 signal produces zero-or-more `notifications` rows by joining
|
||||
against `watches` and applying the §15.1 priority rules. The
|
||||
fan-out lives as a small module that the bot wrapper invokes
|
||||
inline after each write — same chokepoint shape Slice 1's
|
||||
`_log` uses.
|
||||
- **The §15.2 inbox.** `GET /api/notifications` with the
|
||||
`unread` / `rfc_slug` / `category` / `bundled` filter chips,
|
||||
`POST /api/notifications/<id>/read` for per-row marking,
|
||||
`POST /api/notifications/read` for the bulk filter mark, and the
|
||||
SSE `GET /api/notifications/stream` that backs the live badge.
|
||||
- **The §15.3 surface.** The header badge counter (live via the SSE),
|
||||
the toast on personal-direct events while the user is active, and
|
||||
the ambient signal — a colored dot per row on the §7 catalog
|
||||
pointing at watched RFCs with unseen activity.
|
||||
- **The §15.4 email loop.** Per-category opt-in/out preferences on
|
||||
the users table (already in the schema), the `/api/users/me/notification-preferences`
|
||||
endpoints, the email-send adapter that routes a notification's
|
||||
category through the user's category toggle, and the
|
||||
`POST /api/webhooks/email-bounce` receiver that sets the global
|
||||
opt-out. Plus the `GET /api/email/unsubscribe` signed-URL
|
||||
one-click flow.
|
||||
- **The §15.5 digest.** A scheduled-job that runs daily and weekly
|
||||
to roll up unseen notifications into a single email, with the
|
||||
`notification_digests` table tracking what was included so the
|
||||
next digest skips what already shipped.
|
||||
- **The §15.6 watch model.** Auto-watch on first interaction with
|
||||
an RFC, the per-row state column (`watching` / `following` /
|
||||
`muted`), the 90-day auto-decay for unset rows, and the explicit
|
||||
`POST /api/rfcs/<slug>/watch` overrides.
|
||||
- **The §15.7 unread mechanism.** Advance the `branch_chat_seen`
|
||||
cursor on every branch read, reconcile inbox notifications to
|
||||
read when their underlying surface is consumed.
|
||||
- **The §15.8 do-not-disturb.** Quiet-hours config on the user, the
|
||||
per-user notification mute list, the orthogonality vs §6.2's
|
||||
app-wide write-mute.
|
||||
|
||||
What Slice 6 does NOT own:
|
||||
|
||||
- The §14 chrome polish (still Slice 7).
|
||||
- The §12 30/90 branch-hygiene timers (still Slice 8).
|
||||
- The §16 deferred items.
|
||||
- New §15 capabilities — Slice 6 shipped the surface; settings UI
|
||||
is exposure of what's already there, not new behavior.
|
||||
|
||||
The carryovers Slice 6 inherits — the existing `actions` audit log
|
||||
(every signal traces back to a row there per §15.9), the SSE
|
||||
machinery from Slices 2 and 5 (chat-stream and graduate-progress
|
||||
respectively), and the §5 schema's notification tables (already
|
||||
in place from Topic 13).
|
||||
|
||||
The §15 surface depends on the producers being in place; with
|
||||
Slice 5 landing the last structural producer (graduation events,
|
||||
specifically `graduate_complete` as a personal-direct event for
|
||||
the proposer per §15.4), every signal a contributor needs to see
|
||||
is now in the audit log waiting to be fanned out.
|
||||
The carryovers Slice 7 inherits — the existing §14 spec text, the
|
||||
§17 endpoint set including Slice 6's settings endpoints, and the
|
||||
React Router layout already in place.
|
||||
|
||||
The next build session should read `SPEC.md`, `README.md`,
|
||||
`docs/DEV.md`, and `SPEC.md`'s §19.1 and pick up Slice 6 cleanly
|
||||
`docs/DEV.md`, and `SPEC.md`'s §19.1 and pick up Slice 7 cleanly
|
||||
without re-briefing. The working agreement in §19.3 continues to
|
||||
apply: implement the slice, correct the spec only where running
|
||||
code reveals it was wrong at a structural level, accumulate new
|
||||
|
||||
@@ -1139,3 +1139,82 @@
|
||||
.branch-dropdown-item.pre-graduation .branch-meta {
|
||||
font-size: 10px; color: #9ca3af; margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---- §15 / Slice 6: inbox, badge, toasts ---- */
|
||||
|
||||
.inbox-trigger {
|
||||
position: relative; background: transparent; border: 1px solid #e5e7eb;
|
||||
border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 16px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.inbox-trigger:hover { background: #f9fafb; }
|
||||
.inbox-trigger .badge {
|
||||
position: absolute; top: -6px; right: -6px;
|
||||
background: #dc2626; color: white; font-size: 10px;
|
||||
border-radius: 999px; padding: 1px 5px; font-weight: 700;
|
||||
min-width: 16px; text-align: center;
|
||||
}
|
||||
|
||||
.inbox-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.25);
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
padding-top: 60px; z-index: 100;
|
||||
}
|
||||
.inbox-panel {
|
||||
background: white; border: 1px solid #e5e7eb; border-radius: 8px;
|
||||
width: 720px; max-width: 90vw; max-height: 80vh;
|
||||
display: flex; flex-direction: column; box-shadow: 0 12px 32px rgba(0,0,0,0.18);
|
||||
}
|
||||
.inbox-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 16px; border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.inbox-header h2 { margin: 0; font-size: 16px; }
|
||||
.inbox-filters {
|
||||
display: flex; gap: 8px; flex-wrap: wrap;
|
||||
padding: 10px 16px; border-bottom: 1px solid #f3f4f6; align-items: center;
|
||||
}
|
||||
.inbox-filters .chip {
|
||||
font-size: 12px; padding: 4px 8px; background: #f9fafb;
|
||||
border: 1px solid #e5e7eb; border-radius: 999px;
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.inbox-filters .chip input[type=checkbox] { margin-right: 2px; }
|
||||
.inbox-filters select.chip { padding: 4px 8px; }
|
||||
|
||||
.inbox-body { overflow-y: auto; flex: 1; }
|
||||
.inbox-list { list-style: none; margin: 0; padding: 0; }
|
||||
.inbox-row { border-bottom: 1px solid #f3f4f6; }
|
||||
.inbox-row.unread { background: #fffbeb; }
|
||||
.inbox-row-link {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 16px;
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.inbox-row-link:hover { background: #f9fafb; }
|
||||
.inbox-cat {
|
||||
font-size: 9px; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
padding: 2px 6px; border-radius: 4px; font-weight: 700;
|
||||
}
|
||||
.inbox-cat.cat-personal-direct { background: #fef3c7; color: #92400e; }
|
||||
.inbox-cat.cat-structural { background: #dbeafe; color: #1e40af; }
|
||||
.inbox-cat.cat-churn { background: #f3f4f6; color: #4b5563; }
|
||||
.inbox-summary { flex: 1; font-size: 13px; }
|
||||
.inbox-bundle-count {
|
||||
font-size: 11px; color: #6b7280;
|
||||
background: #f3f4f6; padding: 1px 6px; border-radius: 999px;
|
||||
}
|
||||
.inbox-when { font-size: 11px; color: #9ca3af; }
|
||||
|
||||
.toast-host {
|
||||
position: fixed; right: 16px; bottom: 16px;
|
||||
display: flex; flex-direction: column; gap: 8px; z-index: 200;
|
||||
}
|
||||
.toast {
|
||||
padding: 10px 14px; background: white; border: 1px solid #e5e7eb;
|
||||
border-left: 4px solid #6b7280; border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08); font-size: 13px;
|
||||
max-width: 360px; cursor: pointer;
|
||||
}
|
||||
.toast.cat-personal-direct { border-left-color: #d97706; }
|
||||
.toast.cat-structural { border-left-color: #2563eb; }
|
||||
.toast.cat-churn { border-left-color: #6b7280; }
|
||||
|
||||
+53
-1
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Routes, Route, Link, useNavigate } from 'react-router-dom'
|
||||
import { getMe } from './api'
|
||||
import { getMe, subscribeToNotifications } from './api'
|
||||
import Catalog from './components/Catalog.jsx'
|
||||
import Inbox from './components/Inbox.jsx'
|
||||
import RFCView from './components/RFCView.jsx'
|
||||
import PRView from './components/PRView.jsx'
|
||||
import ProposalView from './components/ProposalView.jsx'
|
||||
import ProposeModal from './components/ProposeModal.jsx'
|
||||
import Landing from './components/Landing.jsx'
|
||||
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
||||
import './App.css'
|
||||
|
||||
export default function App() {
|
||||
@@ -14,6 +16,9 @@ export default function App() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [proposeOpen, setProposeOpen] = useState(false)
|
||||
const [catalogVersion, setCatalogVersion] = useState(0)
|
||||
const [inboxOpen, setInboxOpen] = useState(false)
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [inboxTick, setInboxTick] = useState(0)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -23,6 +28,39 @@ export default function App() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// §15.3 — subscribe to the live SSE stream for authenticated viewers
|
||||
// so the badge counter and the toast surface stay in lockstep with
|
||||
// the inbox. Tabs that miss an event because they were closed pick
|
||||
// it up on next sign-in via the snapshot frame.
|
||||
useEffect(() => {
|
||||
if (!me?.authenticated) return undefined
|
||||
const close = subscribeToNotifications({
|
||||
onSnapshot: payload => setUnreadCount(payload.unread_count || 0),
|
||||
onNotification: payload => {
|
||||
setUnreadCount(c => c + 1)
|
||||
setInboxTick(t => t + 1)
|
||||
// §15.3: personal-direct events get a toast even when the user
|
||||
// isn't on the relevant view — they're the named subject.
|
||||
// Churn never toasts; structural toasts only when it lands on
|
||||
// a slug the user is currently viewing (URL match).
|
||||
const isPersonal = payload.category === 'personal-direct'
|
||||
const onCurrentSlug = payload.rfc_slug && window.location.pathname.includes(`/rfc/${payload.rfc_slug}`)
|
||||
if (isPersonal || onCurrentSlug) {
|
||||
showToast({
|
||||
summary: payload.summary,
|
||||
category: payload.category,
|
||||
link: payload.rfc_slug ? `/rfc/${payload.rfc_slug}` : null,
|
||||
})
|
||||
}
|
||||
},
|
||||
onRead: () => {
|
||||
setUnreadCount(c => Math.max(0, c - 1))
|
||||
setInboxTick(t => t + 1)
|
||||
},
|
||||
})
|
||||
return close
|
||||
}, [me?.authenticated])
|
||||
|
||||
if (loading) {
|
||||
return <div className="boot">Loading…</div>
|
||||
}
|
||||
@@ -38,6 +76,16 @@ export default function App() {
|
||||
<Link to="/">Wiggleverse RFCs</Link>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<button
|
||||
className="inbox-trigger"
|
||||
onClick={() => setInboxOpen(o => !o)}
|
||||
title="Notifications inbox (§15.2)"
|
||||
>
|
||||
<span aria-hidden>📮</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="user-name">{me.user.display_name}</span>
|
||||
<span className={`user-role-badge role-${me.user.role}`}>{me.user.role}</span>
|
||||
<a className="btn-link" href="/auth/logout">Sign out</a>
|
||||
@@ -67,6 +115,10 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{inboxOpen && (
|
||||
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
||||
)}
|
||||
<ToastHost />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -399,3 +399,100 @@ export async function streamChatTurn(slug, branch, threadId, { text, quote, mode
|
||||
return { assistantId, userMsgId }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// §15 / Slice 6: notifications surface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listNotifications({ unread, rfcSlug, category, actorUserId, bundled } = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (unread) params.set('unread', '1')
|
||||
if (rfcSlug) params.set('rfc_slug', rfcSlug)
|
||||
if (category) params.set('category', category)
|
||||
if (actorUserId) params.set('actor_user_id', actorUserId)
|
||||
if (bundled) params.set('bundled', '1')
|
||||
const qs = params.toString()
|
||||
return jsonOrThrow(await fetch(`/api/notifications${qs ? `?${qs}` : ''}`))
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id) {
|
||||
return jsonOrThrow(await fetch(`/api/notifications/${id}/read`, { method: 'POST' }))
|
||||
}
|
||||
|
||||
export async function markNotificationsReadByFilter(filter) {
|
||||
return jsonOrThrow(await fetch('/api/notifications/read', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(filter || {}),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function listWatches() {
|
||||
return jsonOrThrow(await fetch('/api/watches'))
|
||||
}
|
||||
|
||||
export async function setWatch(slug, state) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/watch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ state }),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getNotificationPreferences() {
|
||||
return jsonOrThrow(await fetch('/api/users/me/notification-preferences'))
|
||||
}
|
||||
|
||||
export async function setNotificationPreferences(prefs) {
|
||||
return jsonOrThrow(await fetch('/api/users/me/notification-preferences', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(prefs),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getQuietHours() {
|
||||
return jsonOrThrow(await fetch('/api/users/me/quiet-hours'))
|
||||
}
|
||||
|
||||
export async function setQuietHours({ start, end, timezone } = {}) {
|
||||
return jsonOrThrow(await fetch('/api/users/me/quiet-hours', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start: start || null, end: end || null, timezone: timezone || null }),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function muteUser(userId) {
|
||||
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' }))
|
||||
}
|
||||
|
||||
export async function unmuteUser(userId) {
|
||||
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'DELETE' }))
|
||||
}
|
||||
|
||||
export async function advanceChatSeen(slug, branch, lastSeenMessageId) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/branches/${branch}/chat-seen`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ last_seen_message_id: lastSeenMessageId || null }),
|
||||
}))
|
||||
}
|
||||
|
||||
// SSE subscription helper. Returns a close() function. The handler
|
||||
// surface mirrors §15.3: a snapshot event on open, then per-notification
|
||||
// `notification` events, plus `read` events when another tab marks a row.
|
||||
export function subscribeToNotifications({ onSnapshot, onNotification, onRead, onError } = {}) {
|
||||
const source = new EventSource('/api/notifications/stream')
|
||||
source.addEventListener('snapshot', e => {
|
||||
try { onSnapshot?.(JSON.parse(e.data)) } catch {}
|
||||
})
|
||||
source.addEventListener('notification', e => {
|
||||
try { onNotification?.(JSON.parse(e.data)) } catch {}
|
||||
})
|
||||
source.addEventListener('read', e => {
|
||||
try { onRead?.(JSON.parse(e.data)) } catch {}
|
||||
})
|
||||
source.onerror = err => { onError?.(err) }
|
||||
return () => source.close()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// §15.2 — the inbox panel.
|
||||
//
|
||||
// One mental space across every RFC the contributor has any relationship
|
||||
// to. Filter chips (Unread only, RFC: …, Category: personal-direct /
|
||||
// structural / churn) are AND-combined. The bundle toggle collapses
|
||||
// rows by (RFC, event_kind) per §15.2's per-bundle markable surface.
|
||||
//
|
||||
// The header badge in App.jsx subscribes to the same SSE stream and so
|
||||
// stays in lockstep with the inbox per §15.3.
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
listNotifications,
|
||||
markNotificationRead,
|
||||
markNotificationsReadByFilter,
|
||||
} from '../api.js'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: '', label: 'All categories' },
|
||||
{ value: 'personal-direct', label: 'Personal' },
|
||||
{ value: 'structural', label: 'Structural' },
|
||||
{ value: 'churn', label: 'Churn' },
|
||||
]
|
||||
|
||||
export default function Inbox({ onClose, lastChangeTick }) {
|
||||
const [items, setItems] = useState([])
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [filters, setFilters] = useState({
|
||||
unread: false, rfcSlug: '', category: '', bundled: false,
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
listNotifications({
|
||||
unread: filters.unread,
|
||||
rfcSlug: filters.rfcSlug || undefined,
|
||||
category: filters.category || undefined,
|
||||
bundled: filters.bundled,
|
||||
})
|
||||
.then(r => {
|
||||
setItems(r.items || [])
|
||||
setUnreadCount(r.unread_count || 0)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [filters, lastChangeTick])
|
||||
|
||||
const rfcOptions = useMemo(() => {
|
||||
const seen = new Map()
|
||||
for (const it of items) {
|
||||
if (it.rfc_slug && !seen.has(it.rfc_slug)) {
|
||||
seen.set(it.rfc_slug, it.rfc_title || it.rfc_slug)
|
||||
}
|
||||
}
|
||||
return Array.from(seen.entries())
|
||||
}, [items])
|
||||
|
||||
async function handleRowClick(item) {
|
||||
if (!item.read_at) {
|
||||
await markNotificationRead(item.id)
|
||||
setItems(prev => prev.map(p => p.id === item.id ? { ...p, read_at: new Date().toISOString() } : p))
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllUnderFilter() {
|
||||
await markNotificationsReadByFilter({
|
||||
rfc_slug: filters.rfcSlug || undefined,
|
||||
category: filters.category || undefined,
|
||||
})
|
||||
setItems(prev => prev.map(p => ({ ...p, read_at: p.read_at || new Date().toISOString() })))
|
||||
setUnreadCount(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inbox-overlay" onClick={onClose}>
|
||||
<div className="inbox-panel" onClick={e => e.stopPropagation()}>
|
||||
<header className="inbox-header">
|
||||
<h2>Inbox</h2>
|
||||
<button className="btn-link" onClick={onClose}>Close</button>
|
||||
</header>
|
||||
|
||||
<div className="inbox-filters">
|
||||
<label className="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.unread}
|
||||
onChange={e => setFilters(f => ({ ...f, unread: e.target.checked }))}
|
||||
/>
|
||||
Unread only
|
||||
</label>
|
||||
|
||||
<select
|
||||
value={filters.rfcSlug}
|
||||
onChange={e => setFilters(f => ({ ...f, rfcSlug: e.target.value }))}
|
||||
className="chip"
|
||||
>
|
||||
<option value="">All RFCs</option>
|
||||
{rfcOptions.map(([slug, title]) => (
|
||||
<option key={slug} value={slug}>{title}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.category}
|
||||
onChange={e => setFilters(f => ({ ...f, category: e.target.value }))}
|
||||
className="chip"
|
||||
>
|
||||
{CATEGORIES.map(c => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.bundled}
|
||||
onChange={e => setFilters(f => ({ ...f, bundled: e.target.checked }))}
|
||||
/>
|
||||
Bundle by RFC
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={markAllUnderFilter}
|
||||
disabled={items.every(i => i.read_at)}
|
||||
>
|
||||
Mark all read (under filter)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="inbox-body">
|
||||
{loading && <p className="muted">Loading…</p>}
|
||||
{!loading && items.length === 0 && (
|
||||
<p className="muted">No notifications match. Try a different filter, or come back later.</p>
|
||||
)}
|
||||
<ul className="inbox-list">
|
||||
{items.map(item => (
|
||||
<InboxRow key={item.id} item={item} onClick={handleRowClick} onClose={onClose} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InboxRow({ item, onClick, onClose }) {
|
||||
const unread = !item.read_at
|
||||
const target = deepLink(item)
|
||||
const handle = async () => {
|
||||
await onClick(item)
|
||||
if (target) onClose?.()
|
||||
}
|
||||
return (
|
||||
<li className={`inbox-row ${unread ? 'unread' : ''}`}>
|
||||
<Link to={target || '#'} onClick={handle} className="inbox-row-link">
|
||||
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
|
||||
<span className="inbox-summary">{item.summary}</span>
|
||||
{item.bundled_count > 1 && (
|
||||
<span className="inbox-bundle-count">+{item.bundled_count - 1}</span>
|
||||
)}
|
||||
<span className="inbox-when">{formatWhen(item.created_at)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function deepLink(item) {
|
||||
if (item.rfc_slug && item.pr_number) return `/rfc/${item.rfc_slug}/pr/${item.pr_number}`
|
||||
if (item.rfc_slug && item.branch_name) return `/rfc/${item.rfc_slug}?branch=${item.branch_name}`
|
||||
if (item.rfc_slug) return `/rfc/${item.rfc_slug}`
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatWhen(iso) {
|
||||
if (!iso) return ''
|
||||
const dt = new Date(iso.replace(' ', 'T') + (iso.endsWith('Z') ? '' : 'Z'))
|
||||
if (Number.isNaN(dt.getTime())) return iso
|
||||
const diffMs = Date.now() - dt.getTime()
|
||||
const m = Math.floor(diffMs / 60000)
|
||||
if (m < 1) return 'just now'
|
||||
if (m < 60) return `${m}m`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h`
|
||||
const d = Math.floor(h / 24)
|
||||
return `${d}d`
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// §15.3 — the toast surface.
|
||||
//
|
||||
// Toasts fire for the user's own actions completing and for events
|
||||
// landing on the exact view the user is currently looking at. The
|
||||
// inbox row still lands for the same event; the toast just carries
|
||||
// the "something just happened here" beat per §15.3.
|
||||
//
|
||||
// This component is a simple stack with a small cap (4 visible at
|
||||
// once). Newer toasts queue behind the visible ones rather than
|
||||
// stacking endlessly. Auto-dismiss after a short interval; click to
|
||||
// dismiss early.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const MAX_VISIBLE = 4
|
||||
const AUTO_DISMISS_MS = 6000
|
||||
|
||||
let _emit = null
|
||||
|
||||
export function showToast({ summary, category, link }) {
|
||||
if (_emit) _emit({ id: Math.random().toString(36).slice(2), summary, category, link, ts: Date.now() })
|
||||
}
|
||||
|
||||
export default function ToastHost() {
|
||||
const [toasts, setToasts] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
_emit = (t) => setToasts(prev => [...prev, t])
|
||||
return () => { _emit = null }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (toasts.length === 0) return
|
||||
const t = setTimeout(() => {
|
||||
setToasts(prev => prev.slice(1))
|
||||
}, AUTO_DISMISS_MS)
|
||||
return () => clearTimeout(t)
|
||||
}, [toasts])
|
||||
|
||||
const visible = toasts.slice(0, MAX_VISIBLE)
|
||||
return (
|
||||
<div className="toast-host">
|
||||
{visible.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`toast cat-${t.category || 'unknown'}`}
|
||||
onClick={() => setToasts(prev => prev.filter(x => x.id !== t.id))}
|
||||
>
|
||||
{t.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user