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. |
|
| `OWNER_GITEA_LOGIN` | Your owner-zero Gitea login — gets the owner role on first sign-in. |
|
||||||
| `GITEA_WEBHOOK_SECRET` | A shared secret for the §4.1 webhook signature. |
|
| `GITEA_WEBHOOK_SECRET` | A shared secret for the §4.1 webhook signature. |
|
||||||
|
|
||||||
The LLM-provider settings (`ENABLED_MODELS`, `ANTHROPIC_API_KEY`,
|
Optional values, picked up at process start:
|
||||||
etc.) are not exercised by Slice 1 but are wired through `config.py`
|
|
||||||
so the next slice can pick them up.
|
| Variable | What it is |
|
||||||
|
| -------------------------- | --------------------------------------------------------- |
|
||||||
|
| `ENABLED_MODELS` | Comma-separated provider keys for §18 chat (e.g. `claude,gemini`). |
|
||||||
|
| `ANTHROPIC_API_KEY` etc. | Per-provider keys; missing keys disable that provider. |
|
||||||
|
| `SMTP_HOST` / `SMTP_PORT` | §15.4 transactional-email adapter target. Empty falls back to logging the envelope to stdout — sufficient for dev and integration tests. |
|
||||||
|
| `SMTP_USER` / `SMTP_PASSWORD` | SMTP auth credentials. Optional alongside `SMTP_HOST`. |
|
||||||
|
| `SMTP_STARTTLS` | `1` (default) to negotiate STARTTLS; `0` for plaintext. |
|
||||||
|
| `EMAIL_FROM` | Envelope From address for §15.4 mail. Defaults to a non-routable placeholder. |
|
||||||
|
| `EMAIL_FROM_NAME` | Display name on the From header (default `Wiggleverse`). |
|
||||||
|
| `EMAIL_ENABLED` | `1` (default) to dispatch email; `0` to suppress all sends without disabling the inbox. |
|
||||||
|
| `EMAIL_BUNDLE_THRESHOLD` | Held-during-quiet-hours threshold for the "Activity while you were away" bundle (default 5, §15.4). |
|
||||||
|
| `DIGEST_TICK_SECONDS` | Cadence of the §15.5 digest scheduler's loop (default 3600). Tests drive ticks synchronously via `digest.run_tick`. |
|
||||||
|
|
||||||
### 6. Install dependencies
|
### 6. Install dependencies
|
||||||
|
|
||||||
@@ -150,7 +161,7 @@ button at the bottom opens the propose modal.
|
|||||||
|
|
||||||
## What the build lets you do so far
|
## What the build lets you do so far
|
||||||
|
|
||||||
Slices 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).
|
- **Propose → idea PR → merge → super-draft** (Slice 1, §9.1–§9.3).
|
||||||
- **Super-draft body editing** via meta-repo edit branches, with AI
|
- **Super-draft body editing** via meta-repo edit branches, with AI
|
||||||
@@ -172,6 +183,21 @@ Slices 1–5 are shipped. End-to-end paths the app supports today:
|
|||||||
§13 in full).
|
§13 in full).
|
||||||
- **§13.1 ownership claim** as a meta-repo PR adding the claimant
|
- **§13.1 ownership claim** as a meta-repo PR adding the claimant
|
||||||
to the entry's `owners:` field; admin/owner merges the PR (Slice 5).
|
to the entry's `owners:` field; admin/owner merges the PR (Slice 5).
|
||||||
|
- **§15 notifications** end-to-end: a producer-side chokepoint in
|
||||||
|
`notify.py` fans out from `actions` (and from chat-message
|
||||||
|
inserts that don't go through the bot) into `notifications`
|
||||||
|
rows under §15.1's routing rules; §15.6 watches auto-set on the
|
||||||
|
first substantive gesture and decay after 90 days; the header
|
||||||
|
badge and the `/inbox` overlay back the live counter via an SSE
|
||||||
|
stream per §15.3; toasts fire for personal-direct events and for
|
||||||
|
events landing on the view the user is currently watching;
|
||||||
|
§15.4 email opts in per category with one-click unsubscribe and
|
||||||
|
a global opt-out wired to the bounce webhook; §15.5 weekly /
|
||||||
|
daily digest assembles eligible churn into a single mail; §15.7
|
||||||
|
reconciles unread state when a scope cursor advances; §15.8
|
||||||
|
quiet hours hold email and digest while letting the inbox row
|
||||||
|
still land, and the per-user mute suppresses inbox rows
|
||||||
|
produced by a specific actor (Slice 6).
|
||||||
|
|
||||||
This exercises the §4 cache (webhook + reconciler), the §6
|
This exercises the §4 cache (webhook + reconciler), the §6
|
||||||
permission model in full, the §1 bot wrapper (every Git write goes
|
permission model in full, the §1 bot wrapper (every Git write goes
|
||||||
@@ -179,11 +205,11 @@ through it, every commit and PR carries the `On-behalf-of:`
|
|||||||
trailer), and the §17 routing-collapse rule that lets active and
|
trailer), and the §17 routing-collapse rule that lets active and
|
||||||
super-draft surfaces share their endpoints.
|
super-draft surfaces share their endpoints.
|
||||||
|
|
||||||
Out of scope for the slices shipped so far: notifications (Slice 6,
|
Out of scope for the slices shipped so far: landing-page and
|
||||||
§15), landing-page and `/philosophy` chrome polish (Slice 7, §14),
|
`/philosophy` chrome polish, the notification-settings UI surface,
|
||||||
the §12 30/90 branch-hygiene timers (Slice 8). The full slicing
|
and the admin neighbourhood (Slice 7, §14 + §19.2 candidates); the
|
||||||
plan and the next slice's brief live in
|
§12 30/90 branch-hygiene timers (Slice 8). The full slicing plan
|
||||||
[`docs/DEV.md`](./docs/DEV.md).
|
and the next slice's brief live in [`docs/DEV.md`](./docs/DEV.md).
|
||||||
|
|
||||||
## Verifying it worked
|
## Verifying it worked
|
||||||
|
|
||||||
|
|||||||
@@ -2405,96 +2405,93 @@ surface. With Topic 13 folded in, the structural surface is
|
|||||||
complete. What follows is no longer "topics that block specifying
|
complete. What follows is no longer "topics that block specifying
|
||||||
v1" but "topics to address during or shortly after the v1 build."
|
v1" but "topics to address during or shortly after the v1 build."
|
||||||
|
|
||||||
### 19.1 Next slice: notifications per §15
|
### 19.1 Next slice: the §14 chrome and the settings neighborhood
|
||||||
|
|
||||||
Slice 5 of the build has landed. The §13 graduation flow runs
|
Slice 6 of the build has landed. The §15 notifications surface runs
|
||||||
end-to-end against the local Gitea — the Graduate dialog renders
|
end-to-end against the local Gitea — every `actions` row whose
|
||||||
the three editable fields (integer ID, repo name, initial owners)
|
`action_kind` maps to a §15.1 event fans out through
|
||||||
with the debounced `GET /api/rfcs/<slug>/graduate/check` lighting
|
`notify.fan_out_from_action`, called inline from `bot._log` and
|
||||||
up per-field validity inline, the precondition popover surfaces
|
from the graduation orchestrator's `_audit`. Chat-message inserts
|
||||||
open body-edit PRs via `GET /api/rfcs/<slug>/blocking-prs` (the
|
take a parallel path through `notify.fan_out_chat_message` from
|
||||||
§9.8 gate enforced before the sequence starts), and confirming the
|
inside `chat.append_user_message`, since chat doesn't flow through
|
||||||
dialog kicks off the §13.3 five-step sequence streamed via
|
the bot wrapper. The §15.6 auto-watch upsert sits in the same
|
||||||
`GET /api/rfcs/<slug>/graduate/progress`. The orchestrator in
|
chokepoint — every substantive gesture either creates a `watching`
|
||||||
`api_graduation.py` runs the sequence as an asyncio task fed by an
|
row or bumps `last_participation_at` for the 90-day decay timer.
|
||||||
in-memory queue; each step's bot primitive
|
|
||||||
(`create_rfc_repo_for_graduation`, `seed_graduated_rfc`,
|
|
||||||
`open_graduation_pr`, `merge_graduation_pr`) lands its own row in
|
|
||||||
`actions`, bracketed by `graduate_start` and `graduate_complete`
|
|
||||||
for the linkable sequence. Rollback is per-step and runs in
|
|
||||||
reverse: each forward step has a paired undo registered in
|
|
||||||
`_UNDO_BY_STEP` — the create-repo undo deletes the repo (which
|
|
||||||
also reclaims the seed commits, so seed-files' undo folds into
|
|
||||||
it), the open-pr undo closes the graduation PR. There is no
|
|
||||||
merge-pr undo by design; once the meta-repo merge has landed,
|
|
||||||
graduation is irreversible per §13.5.
|
|
||||||
|
|
||||||
§13.4's chat migration landed as a database semantic no-op —
|
The §15.4 email loop runs through an SMTP adapter with a stdout
|
||||||
the whole-doc main thread on the super-draft
|
fallback for dev — the in-memory `_SENT` buffer is what the
|
||||||
(`rfc_slug=<slug>`, `branch_name='main'`) is the same row before
|
integration tests read from. The per-category dispatch holds during
|
||||||
and after graduation; only the interpretation changes (canonical-
|
§15.8 quiet hours; on window-end, `email.flush_pending` bundles
|
||||||
body view becomes per-RFC repo's main). The slug is the canonical
|
above the §15.4 threshold into a single "Activity while you were
|
||||||
key per §2.3, so no data movement is needed. Edit-branch chats
|
away" mail. The signed-URL unsubscribe path flips a single category
|
||||||
stay attached to their original `branch_name` per §9.8's
|
column to zero; the bounce webhook flips the new `email_opt_out_all`
|
||||||
no-data-movement framing; the §9.8 pre-graduation history
|
column (migration `008_email_opt_out.sql`).
|
||||||
affordance on the new RFC view surfaces them as a distinct
|
|
||||||
disclosure in the breadcrumb dropdown, with the read path
|
|
||||||
dispatching against the meta repo via a new `_is_meta_target(rfc,
|
|
||||||
branch)` helper that handles both super-draft branches and active-
|
|
||||||
RFC pre-graduation meta-repo branches uniformly.
|
|
||||||
|
|
||||||
The §13.1 claim flow landed alongside graduation since it's the
|
The §15.5 digest is a `DigestScheduler` wrapping `cache.Reconciler`'s
|
||||||
prerequisite for non-admin graduation. The bot grew `open_claim_pr`;
|
shape, with a `run_tick` seam the tests drive synchronously. Each
|
||||||
`api_prs._require_pr` broadened to accept `pr_kind='meta_claim'`
|
tick releases held emails, runs the §15.6 90-day decay sweep, and
|
||||||
so the merge surface inherits structurally from §10. Until §13.1's
|
assembles per-cadence digests where the window has rolled over.
|
||||||
claim runs, the dialog refuses the start when `owners=[]` and the
|
The §15.5 exclusion rules (already-emailed, already-read,
|
||||||
popover surfaces "Claim ownership yourself" as a remediation
|
personal-direct-excluded) keep two consecutive ticks idempotent.
|
||||||
affordance (admins are contributors per §6.1 and can claim solo).
|
|
||||||
|
|
||||||
The five §17 routes Slice 5 added — `claim`, `blocking-prs`,
|
§15.2 / §15.3 / §15.7 / §15.8 surface as fourteen endpoints in
|
||||||
`graduate/check`, `graduate`, and `graduate/progress` — live in
|
`backend/app/api_notifications.py`, plus the chat-seen advance on
|
||||||
`backend/app/api_graduation.py`. The §5 schema needed no migration.
|
`api_branches` and the existing PR seen-cursor on `api_prs` — both
|
||||||
On the frontend, `RFCView.jsx`'s breadcrumb actions grew
|
extended to call `notify.reconcile_seen_advance` so the §15.7
|
||||||
`Graduate to RFC repo` and `Claim ownership` buttons;
|
visit-advances-cursor loop closes back into the inbox-row read
|
||||||
`GraduateDialog.jsx` owns the three-field surface, the precondition
|
state. The SSE stream holds a per-user subscriber queue keyed by
|
||||||
popover, and the live step stack fed by an `EventSource` on the
|
user_id; multiple browser tabs see the same events.
|
||||||
progress SSE; the `BranchDropdown` gains a `Pre-graduation history`
|
|
||||||
disclosure that surfaces edit-branch threads on the new RFC view
|
|
||||||
per §9.8.
|
|
||||||
|
|
||||||
Slice 5 ships covered by `backend/tests/test_graduation_vertical.py`
|
On the frontend, `App.jsx` grew a header badge (cap "99+",
|
||||||
— ten integration tests against the FakeGitea (extended with
|
clicking opens the inbox overlay), an SSE-driven counter that
|
||||||
`DELETE /repos/{owner}/{repo}` for the rollback inverse) covering
|
surfaces personal-direct toasts (own-name signals) and live-view
|
||||||
the dialog validator's per-field checks, the no-owners refusal,
|
toasts (events landing on the slug the user is viewing). The
|
||||||
the §9.8 precondition refusing the start, the §13.3 happy path
|
inbox is `Inbox.jsx` — three filter chips (Unread only, RFC,
|
||||||
end-to-end with audit-log verification, mid-sequence rollback at
|
Category), a Bundle toggle, and a "Mark all read (under filter)"
|
||||||
step 2 (seed) and step 3 (PR open), concurrent-graduation refusal,
|
button. `ToastHost.jsx` caps four visible at once with auto-dismiss.
|
||||||
§13.4's chat-row-survives contract, the §9.8 pre-graduation
|
|
||||||
history surface, and the §13.1 claim PR cycle. The full Slices 1–5
|
|
||||||
test suite is 45/45 green.
|
|
||||||
|
|
||||||
**Slice 6 is notifications per §15.** Every other vertical now
|
The §15.9 attribution rule fell out cleanly: every `notifications`
|
||||||
produces signals — propose, claim, merge, graduate, body edits,
|
row carries `actor_user_id` resolved from the `actions.actor_user_id`
|
||||||
manual flushes, PR open/withdraw/merge, review threads, conflict-
|
in the originating audit row (the underlying user, never the bot).
|
||||||
replay — and Slice 6 builds the surface that turns those signals
|
System-generated events (digest emission, 90-day decay) leave
|
||||||
into a contributor's inbox. The §5 schema already carries the
|
`actor_user_id` NULL and render as "the app." AI participation
|
||||||
notifications, watches, branch_chat_seen, notification_user_mutes,
|
events landed as null-system per §19.2's candidate naming — when a
|
||||||
and notification_digests tables; Topic 13's session settled the
|
chat message authored by an AI provider goes through, no actor row
|
||||||
producer-side rules per §15.1, the §15.2 inbox grouping, §15.3
|
is written, since the LLM call doesn't have a user_id; the topic
|
||||||
badges and toasts, §15.4 email categories, §15.5 digest cadence,
|
folder for "AI participation as a notification source" in §19.2
|
||||||
§15.6 watch/subscription, §15.7 unread mechanism, §15.8 do-not-
|
remains open for explicit settling.
|
||||||
disturb, and §15.9 attribution. The producer-side hook is "after
|
|
||||||
a write succeeds, evaluate watches and fan-out notification rows"
|
Slice 6 ships covered by `backend/tests/test_notifications_vertical.py`
|
||||||
— same chokepoint shape Slice 1's `_log` uses, invoked inline
|
— seventeen integration tests covering the producer-side fan-out
|
||||||
from the bot wrapper. The consumer-side hook is the header badge,
|
on the propose/merge/decline chain, §15.6 auto-watch, the §15.2
|
||||||
the inbox panel, the toast surface, and the per-row read-state
|
inbox listing with filter chips, the §15.7 chat-seen reconciler,
|
||||||
machinery. The §15.4 email loop and the §15.5 digest are the
|
the §15.8 per-user mute and the per-RFC mute, the §15.4 email-
|
||||||
heavier sub-pieces — the digest needs a scheduled-job runner;
|
bounce webhook flipping the global opt-out, the `/email/unsubscribe`
|
||||||
the email loop needs a transactional-email adapter and the
|
signed-URL path, the §15.8 quiet-hours email hold, the §15.5
|
||||||
`POST /api/webhooks/email-bounce` receiver.
|
digest's emit-then-skip behavior across two consecutive ticks,
|
||||||
|
preferences and quiet-hours round-trips, the explicit-watch
|
||||||
|
override that prevents auto-downgrade, and the SSE subscriber/
|
||||||
|
broadcast substrate. The full Slices 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`,
|
The next build session should read `SPEC.md`, `README.md`,
|
||||||
`docs/DEV.md`, and this §19.1 entry and pick up Slice 6 cleanly
|
`docs/DEV.md`, and this §19.1 entry and pick up Slice 7 cleanly
|
||||||
without re-briefing. The working agreement in §19.3 continues to
|
without re-briefing. The working agreement in §19.3 continues to
|
||||||
apply: implement the slice, correct the spec only where running
|
apply: implement the slice, correct the spec only where running
|
||||||
code reveals it was wrong at a structural level, accumulate new
|
code reveals it was wrong at a structural level, accumulate new
|
||||||
@@ -2747,6 +2744,56 @@ binding.
|
|||||||
minimum that keeps the test surface terse without adding a
|
minimum that keeps the test surface terse without adding a
|
||||||
separate test-only module.
|
separate test-only module.
|
||||||
- **Body full-text search.** When the time comes.
|
- **Body full-text search.** When the time comes.
|
||||||
|
- **The §15.2 inbox grouping's per-RFC + per-event-kind bundle's
|
||||||
|
represent-row choice.** Slice 6's bundle implementation collapses
|
||||||
|
rows under the (rfc_slug, event_kind) key and picks the most-recent
|
||||||
|
constituent as the representative. The §15.2 spec voice ("3 new
|
||||||
|
commits on PR #4 / RFC-0042" as a single bundle row) names the
|
||||||
|
count but not which representative's verb-phrase the bundle reads
|
||||||
|
as. A future session may settle whether the bundle reads in the
|
||||||
|
voice of the most-recent actor ("alice + 2 others added commits")
|
||||||
|
or a structural verb ("3 new commits on …"), and how the bundle
|
||||||
|
expands to its constituents (inline disclosure, modal, navigation).
|
||||||
|
Defer-able until usage shows the per-row shape doesn't suffice.
|
||||||
|
- **AI participation as a notification source — confirmed.** §19.2
|
||||||
|
already named this as a candidate; Slice 6 didn't settle it. The
|
||||||
|
build chose null-system for AI-generated content for now (no
|
||||||
|
`actor_user_id` since the LLM call has no user row), but the
|
||||||
|
§15.9 framing of "the system did not invent attribution" reads
|
||||||
|
cleanly only for genuinely unattributed events (auto-close,
|
||||||
|
digest emission). An AI-authored chat reply produces a chat
|
||||||
|
message and could fire a chat_message_in_participated_thread
|
||||||
|
signal to other thread participants, with the actor reading as
|
||||||
|
"the AI participant" — a candidate distinct entity. Touches
|
||||||
|
§15.9 (the actor slot in inbox prose), §8.12 (the AI participant's
|
||||||
|
authored-message shape), and the §19.2 per-RFC model availability
|
||||||
|
topic (which AI participant is the right noun for a row coming
|
||||||
|
out of that RFC?).
|
||||||
|
- **Inbox row prose for null-actor events.** Slice 6 renders
|
||||||
|
null-actor rows with the literal noun "the app" per §15.9's
|
||||||
|
"absence of an actor is the honest signal" framing. The phrase
|
||||||
|
works for some events (the digest emission email body) but
|
||||||
|
reads awkwardly for others ("the app started a resolution
|
||||||
|
branch"). A future session may settle a per-event-kind null-
|
||||||
|
actor verb form so each row reads naturally without picking up
|
||||||
|
an apparent personification. Defer-able until contributor
|
||||||
|
feedback surfaces an irritating render.
|
||||||
|
- **Email bounce webhook authentication.** Slice 6's
|
||||||
|
`/api/webhooks/email-bounce` accepts unauthenticated POSTs for
|
||||||
|
v1 — the SMTP provider's callback URL is the contract. When an
|
||||||
|
actual provider is wired in, the webhook needs a shared secret
|
||||||
|
or signature verification (Sendgrid's signed events, AWS SES's
|
||||||
|
SNS topic signature, etc.). Trivial to add per provider; the
|
||||||
|
routing-and-flip-the-column logic doesn't change.
|
||||||
|
- **Per-user mute exemption checks for arbiters.** §15.8 commits
|
||||||
|
that arbiters cannot mute participants on RFCs where they hold
|
||||||
|
authority. Slice 6's check uses "the muted user has a watches
|
||||||
|
row on the same RFC where the muter is an arbiter" as the
|
||||||
|
participation proxy. The spec doesn't define "active" precisely
|
||||||
|
for this check; the watches-row proxy is generous (a user with a
|
||||||
|
read-only relationship counts as active). A future session may
|
||||||
|
settle a tighter definition (e.g., has any `actions` row on the
|
||||||
|
RFC) if the generous proxy refuses too many legitimate mutes.
|
||||||
|
|
||||||
Topic 13 (notifications) is settled and folded into §5 (the
|
Topic 13 (notifications) is settled and folded into §5 (the
|
||||||
notifications, watches, branch_chat_seen, notification_user_mutes,
|
notifications, watches, branch_chat_seen, notification_user_mutes,
|
||||||
|
|||||||
+4
-1
@@ -17,7 +17,7 @@ from typing import Any
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from . import api_branches, api_graduation, api_prs, auth, db, entry as entry_mod, cache
|
from . import api_branches, api_graduation, api_notifications, api_prs, auth, db, entry as entry_mod, cache
|
||||||
from .bot import Bot
|
from .bot import Bot
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .gitea import Gitea, GiteaError
|
from .gitea import Gitea, GiteaError
|
||||||
@@ -55,6 +55,9 @@ def make_router(
|
|||||||
router.include_router(api_prs.make_router(config, gitea, bot, providers))
|
router.include_router(api_prs.make_router(config, gitea, bot, providers))
|
||||||
# Slice 5: §13 graduation + §13.1 claim.
|
# Slice 5: §13 graduation + §13.1 claim.
|
||||||
router.include_router(api_graduation.make_router(config, gitea, bot))
|
router.include_router(api_graduation.make_router(config, gitea, bot))
|
||||||
|
# Slice 6: §15 notifications surface (inbox, watches, prefs,
|
||||||
|
# quiet hours, per-user mute, email unsubscribe, bounce webhook).
|
||||||
|
router.include_router(api_notifications.make_router(config))
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Auth surface — extends the prototype's pattern but reads role
|
# Auth surface — extends the prototype's pattern but reads role
|
||||||
|
|||||||
@@ -901,6 +901,35 @@ def make_router(
|
|||||||
)
|
)
|
||||||
return {"ok": True, "message_id": message_id}
|
return {"ok": True, "message_id": message_id}
|
||||||
|
|
||||||
|
@router.post("/api/rfcs/{slug}/branches/{branch}/chat-seen")
|
||||||
|
async def advance_chat_seen(slug: str, branch: str, body: dict, request: Request) -> dict[str, Any]:
|
||||||
|
"""§15.7 chat-seen cursor advance.
|
||||||
|
|
||||||
|
Body: `{"last_seen_message_id": <int>}`. Upserts branch_chat_seen
|
||||||
|
and runs the §15.7 reconciler — every unread notification scoped
|
||||||
|
to this (slug, branch) on or before the new cursor is marked read.
|
||||||
|
"""
|
||||||
|
viewer = auth.require_user(request)
|
||||||
|
_require_rfc_with_repo(slug)
|
||||||
|
if not _can_read_branch(slug, branch, viewer):
|
||||||
|
raise HTTPException(403, "Branch is private")
|
||||||
|
last_seen = int(body.get("last_seen_message_id") or 0) or None
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at)
|
||||||
|
VALUES (?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(user_id, rfc_slug, branch_name) DO UPDATE SET
|
||||||
|
last_seen_message_id = excluded.last_seen_message_id,
|
||||||
|
seen_at = excluded.seen_at
|
||||||
|
""",
|
||||||
|
(viewer.user_id, slug, branch, last_seen),
|
||||||
|
)
|
||||||
|
from . import notify
|
||||||
|
reconciled = notify.reconcile_seen_advance(
|
||||||
|
user_id=viewer.user_id, rfc_slug=slug, branch_name=branch,
|
||||||
|
)
|
||||||
|
return {"ok": True, "reconciled": reconciled}
|
||||||
|
|
||||||
@router.post("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/resolve")
|
@router.post("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/resolve")
|
||||||
async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
|
async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
|
||||||
viewer = auth.require_contributor(request)
|
viewer = auth.require_contributor(request)
|
||||||
|
|||||||
@@ -907,6 +907,17 @@ def _audit(
|
|||||||
json.dumps(details) if details else None,
|
json.dumps(details) if details else None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
# §15 chokepoint per Slice 6: the bracket rows (graduate_start,
|
||||||
|
# graduate_complete) drive their own notifications per §15.1.
|
||||||
|
from . import notify
|
||||||
|
notify.fan_out_from_action(
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
action_kind=action_kind,
|
||||||
|
rfc_slug=rfc_slug,
|
||||||
|
branch_name=branch_name,
|
||||||
|
pr_number=pr_number,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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),
|
(viewer.user_id, slug, pr_number, new_sha, new_msg),
|
||||||
)
|
)
|
||||||
return {"ok": True}
|
# §15.7 reconciler: a scope cursor advance marks unread
|
||||||
|
# notifications scoped to this PR read.
|
||||||
|
from . import notify
|
||||||
|
reconciled = notify.reconcile_seen_advance(
|
||||||
|
user_id=viewer.user_id, rfc_slug=slug, pr_number=pr_number,
|
||||||
|
)
|
||||||
|
return {"ok": True, "reconciled": reconciled}
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# §10.4: post a review-kind thread anchored to a diff range.
|
# §10.4: post a review-kind thread anchored to a diff range.
|
||||||
|
|||||||
+13
-1
@@ -25,7 +25,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from . import db
|
from . import db, notify
|
||||||
from .gitea import Gitea
|
from .gitea import Gitea
|
||||||
|
|
||||||
|
|
||||||
@@ -89,6 +89,18 @@ def _log(
|
|||||||
json.dumps(details) if details else None,
|
json.dumps(details) if details else None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
# §15 chokepoint per §19.1 brief: fan-out runs inline after the
|
||||||
|
# audit row lands. notify.py owns the routing rules and the
|
||||||
|
# auto-watch upsert per §15.6; the call is intentionally a
|
||||||
|
# single line here so the chokepoint is one place to read.
|
||||||
|
notify.fan_out_from_action(
|
||||||
|
actor_user_id=actor.user_id,
|
||||||
|
action_kind=action_kind,
|
||||||
|
rfc_slug=rfc_slug,
|
||||||
|
branch_name=branch_name,
|
||||||
|
pr_number=pr_number,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Bot:
|
class Bot:
|
||||||
|
|||||||
+40
-1
@@ -139,7 +139,46 @@ def append_user_message(
|
|||||||
""",
|
""",
|
||||||
(thread_id, author_user_id, text, quote),
|
(thread_id, author_user_id, text, quote),
|
||||||
)
|
)
|
||||||
return cur.lastrowid
|
message_id = cur.lastrowid
|
||||||
|
# §15 chokepoint per Slice 6: chat messages don't flow through the
|
||||||
|
# bot wrapper (no Git write), so the fan-out is anchored here. The
|
||||||
|
# routing is: prior thread authors get personal-direct
|
||||||
|
# chat_reply_to_my_message; RFC watchers get churn-class
|
||||||
|
# chat_message_in_participated_thread. The notify module resolves
|
||||||
|
# the thread's RFC/branch context internally.
|
||||||
|
_fan_out_chat(thread_id, author_user_id, message_id)
|
||||||
|
return message_id
|
||||||
|
|
||||||
|
|
||||||
|
def _fan_out_chat(thread_id: int, author_user_id: int, message_id: int) -> None:
|
||||||
|
from . import notify
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT rfc_slug, branch_name, thread_kind FROM threads WHERE id = ?",
|
||||||
|
(thread_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None or not row["rfc_slug"]:
|
||||||
|
return
|
||||||
|
pr_number = None
|
||||||
|
if row["thread_kind"] == "review":
|
||||||
|
pr_row = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT pr_number FROM cached_prs
|
||||||
|
WHERE rfc_slug = ? AND head_branch = ? AND state = 'open'
|
||||||
|
ORDER BY pr_number DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
(row["rfc_slug"], row["branch_name"]),
|
||||||
|
).fetchone()
|
||||||
|
if pr_row:
|
||||||
|
pr_number = pr_row["pr_number"]
|
||||||
|
notify.fan_out_chat_message(
|
||||||
|
actor_user_id=author_user_id,
|
||||||
|
rfc_slug=row["rfc_slug"],
|
||||||
|
branch_name=row["branch_name"] or "main",
|
||||||
|
thread_id=thread_id,
|
||||||
|
message_id=message_id,
|
||||||
|
is_review_thread=(row["thread_kind"] == "review"),
|
||||||
|
pr_number=pr_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def append_assistant_placeholder(*, thread_id: int, model_id: str) -> int:
|
def append_assistant_placeholder(*, thread_id: int, model_id: str) -> int:
|
||||||
|
|||||||
@@ -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 fastapi.responses import RedirectResponse
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from . import api as api_routes, auth, cache, db, providers as providers_mod, webhooks
|
from . import api as api_routes, auth, cache, db, digest, providers as providers_mod, webhooks
|
||||||
from .bot import Bot
|
from .bot import Bot
|
||||||
from .config import load_config
|
from .config import load_config
|
||||||
from .gitea import Gitea
|
from .gitea import Gitea
|
||||||
@@ -31,6 +31,7 @@ async def lifespan(app: FastAPI):
|
|||||||
gitea = Gitea(config)
|
gitea = Gitea(config)
|
||||||
bot = Bot(gitea)
|
bot = Bot(gitea)
|
||||||
reconciler = cache.Reconciler(config, gitea)
|
reconciler = cache.Reconciler(config, gitea)
|
||||||
|
digest_sched = digest.DigestScheduler()
|
||||||
|
|
||||||
# §18 carryover: the multi-provider LLM abstraction. Provider
|
# §18 carryover: the multi-provider LLM abstraction. Provider
|
||||||
# construction can fail (missing key, wrong env value) — if it does,
|
# construction can fail (missing key, wrong env value) — if it does,
|
||||||
@@ -53,10 +54,12 @@ async def lifespan(app: FastAPI):
|
|||||||
app.include_router(webhooks.make_router(config, gitea))
|
app.include_router(webhooks.make_router(config, gitea))
|
||||||
|
|
||||||
reconciler.start()
|
reconciler.start()
|
||||||
|
digest_sched.start()
|
||||||
log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo)
|
log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
await digest_sched.stop()
|
||||||
await reconciler.stop()
|
await reconciler.stop()
|
||||||
await gitea.close()
|
await gitea.close()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
of a public PR, and the full §10.9 conflict-replay path including
|
||||||
the auto-close of the original PR on the resolution PR's merge.
|
the auto-close of the original PR on the resolution PR's merge.
|
||||||
|
|
||||||
|
### Slice 6 — shipped
|
||||||
|
|
||||||
|
Notifications per §15 in full, end-to-end against the local Gitea.
|
||||||
|
|
||||||
|
The producer-side chokepoint lives in
|
||||||
|
[`backend/app/notify.py`](../backend/app/notify.py). Every bot
|
||||||
|
`_log` call drops into `notify.fan_out_from_action`, which upserts
|
||||||
|
the actor's `watches` row per §15.6's substantive-gesture rule and
|
||||||
|
runs the §15.1 routing table to insert zero-or-more `notifications`
|
||||||
|
rows. Chat-message inserts (the second writer surface, since chat
|
||||||
|
doesn't flow through the bot) call `notify.fan_out_chat_message`
|
||||||
|
from inside `chat.append_user_message` — same chokepoint shape, one
|
||||||
|
place to read the routing. The graduation orchestrator's `_audit`
|
||||||
|
helper folds into the same fan-out so `graduate_start` /
|
||||||
|
`graduate_complete` ride the chokepoint too.
|
||||||
|
|
||||||
|
§15.4 email lives in [`backend/app/email.py`](../backend/app/email.py).
|
||||||
|
The SMTP adapter wraps Python's `smtplib`; when `SMTP_HOST` is unset
|
||||||
|
it falls back to logging the envelope (and appending it to an
|
||||||
|
in-memory `_SENT` buffer the integration tests read from). The
|
||||||
|
per-category dispatch consults the recipient's toggles, holds
|
||||||
|
during §15.8 quiet hours, and on quiet-hours window-end the
|
||||||
|
`flush_pending` pass bundles into a single "Activity while you were
|
||||||
|
away" mail when more than `EMAIL_BUNDLE_THRESHOLD` accumulated.
|
||||||
|
One-click unsubscribe is a signed token over `(user_id, category)`;
|
||||||
|
the bounce webhook flips `email_opt_out_all` on the user (new
|
||||||
|
column added by migration 008).
|
||||||
|
|
||||||
|
§15.5 digest lives in [`backend/app/digest.py`](../backend/app/digest.py)
|
||||||
|
as a `DigestScheduler` mirroring `cache.Reconciler`'s shape. The
|
||||||
|
`run_tick` function is the test seam — integration tests drive
|
||||||
|
ticks synchronously, production runs the loop on a `DIGEST_TICK_SECONDS`
|
||||||
|
cadence (default 3600s). Each tick releases held emails, decays
|
||||||
|
§15.6 `watching` rows whose `last_participation_at` is >90 days
|
||||||
|
old, and assembles digests for users whose cadence window has
|
||||||
|
rolled over per `notification_digests.period_end`.
|
||||||
|
|
||||||
|
§15.2 / §15.3 / §15.7 / §15.8 surface as the twelve endpoints in
|
||||||
|
[`backend/app/api_notifications.py`](../backend/app/api_notifications.py)
|
||||||
|
plus the §15.7 chat-seen advance on `api_branches` and the PR
|
||||||
|
seen-cursor advance on `api_prs` — both extended to call
|
||||||
|
`notify.reconcile_seen_advance` so visit-advances-cursor closes the
|
||||||
|
inbox-read loop per §15.7. The `/api/notifications/stream` SSE
|
||||||
|
handler holds a per-user subscriber queue keyed by user_id; one
|
||||||
|
event per browser tab, all subscribers for a user receive every
|
||||||
|
event so the badge counter stays in lockstep across tabs.
|
||||||
|
|
||||||
|
| Method | Path | § |
|
||||||
|
| ------ | ------------------------------------------------- | ------- |
|
||||||
|
| GET | `/api/notifications` | §15.2 |
|
||||||
|
| POST | `/api/notifications/{id}/read` | §15.2 |
|
||||||
|
| POST | `/api/notifications/read` | §15.2 |
|
||||||
|
| GET | `/api/notifications/stream` | §15.3 |
|
||||||
|
| GET | `/api/watches` | §15.6 |
|
||||||
|
| POST | `/api/rfcs/{slug}/watch` | §15.6 |
|
||||||
|
| POST | `/api/rfcs/{slug}/branches/{branch}/chat-seen` | §15.7 |
|
||||||
|
| GET | `/api/users/me/notification-preferences` | §15.4/5 |
|
||||||
|
| POST | `/api/users/me/notification-preferences` | §15.4/5 |
|
||||||
|
| GET | `/api/users/me/quiet-hours` | §15.8 |
|
||||||
|
| POST | `/api/users/me/quiet-hours` | §15.8 |
|
||||||
|
| POST | `/api/users/{id}/notification-mute` | §15.8 |
|
||||||
|
| DELETE | `/api/users/{id}/notification-mute` | §15.8 |
|
||||||
|
| GET | `/api/email/unsubscribe` | §15.4 |
|
||||||
|
| POST | `/api/webhooks/email-bounce` | §15.4 |
|
||||||
|
|
||||||
|
On the frontend, `App.jsx` grew a header badge button (`📮` glyph
|
||||||
|
with a 99+-capped unread count) that opens the inbox overlay. The
|
||||||
|
overlay is `Inbox.jsx` — three filter chips (Unread only, RFC,
|
||||||
|
Category) plus a Bundle toggle and a "Mark all read (under filter)"
|
||||||
|
action. The badge subscribes to the SSE stream alongside the
|
||||||
|
overlay so they share a counter. `ToastHost.jsx` renders personal-
|
||||||
|
direct toasts and live-view toasts (an event firing on the slug
|
||||||
|
the URL points at), capped at four visible at a time with auto-
|
||||||
|
dismiss after a short interval.
|
||||||
|
|
||||||
|
Slice 6 ships covered by
|
||||||
|
[`backend/tests/test_notifications_vertical.py`](../backend/tests/test_notifications_vertical.py) —
|
||||||
|
seventeen integration tests covering the producer-side fan-out for
|
||||||
|
the propose/merge/decline chain, §15.6 auto-watch on first
|
||||||
|
interaction, the §15.2 inbox listing with filter chips, the §15.7
|
||||||
|
chat-seen reconciler, the §15.8 per-user mute and the per-RFC mute,
|
||||||
|
the §15.4 email-bounce webhook, the `/email/unsubscribe` signed-URL
|
||||||
|
path, the §15.8 quiet-hours hold, the §15.5 digest's emit-then-skip
|
||||||
|
behavior across two consecutive ticks, preferences and quiet-hours
|
||||||
|
round-trips, the explicit-watch override that prevents auto-downgrade,
|
||||||
|
and the SSE subscriber/broadcast substrate. The full Slices 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
|
### Slice 5 — shipped
|
||||||
|
|
||||||
Graduation per §13 in full. The §13.3 five-step transactional sequence
|
Graduation per §13 in full. The §13.3 five-step transactional sequence
|
||||||
@@ -416,86 +510,52 @@ spec:
|
|||||||
|
|
||||||
## Next slice
|
## Next slice
|
||||||
|
|
||||||
**Slice 6: notifications per §15.**
|
**Slice 7: the §14 chrome.**
|
||||||
|
|
||||||
Every other vertical now produces signals: propose, claim, merge,
|
With Slice 6 shipped, every structural and notification beat the
|
||||||
graduate, body edits, manual flushes, PR open/withdraw/merge,
|
framework commits to is live: propose, claim, super-draft body
|
||||||
review threads, conflict-replay, super-draft chat. Slice 6 builds
|
editing, the §10 PR flow against both repo shapes, graduation, and
|
||||||
the inbox, the fan-out, the digest, and the email loop that turn
|
the §15 inbox/email/digest stack. What remains for v1 is the chrome
|
||||||
those signals into a contributor's surface. The §5 schema already
|
that wraps the whole thing — the landing page that brings an
|
||||||
carries the notifications, watches, branch_chat_seen,
|
unauthenticated visitor in, the `/philosophy` route that surfaces
|
||||||
notification_user_mutes, and notification_digests tables; Topic 13's
|
[`PHILOSOPHY.md`](../PHILOSOPHY.md) verbatim, the persistent About
|
||||||
session settled the producer-side rules per §15.1 (the signal-surface
|
link in the header per §14.3, plus the natural neighbors that
|
||||||
stack), the §15.2 inbox grouping, §15.3 badges and toasts, §15.4
|
Slice 6 left as API-only and that §19.2 names as candidates:
|
||||||
email categories, §15.5 digest cadence, §15.6 watch/subscription,
|
|
||||||
§15.7 unread mechanism, §15.8 do-not-disturb, and §15.9 attribution.
|
|
||||||
|
|
||||||
Slices 1–5 left this clean: every user gesture goes through the
|
- **The notification-settings surface** — the actual UI for the
|
||||||
bot wrapper and lands an `actions` row with the underlying actor.
|
preferences/quiet-hours/mute endpoints Slice 6 wired. Topic 13
|
||||||
The producer-side hook is "after a write succeeds, evaluate watches
|
settled the schema and the per-category rules; the surface
|
||||||
and fan-out notification rows." The consumer-side hook is the
|
where a contributor finds the per-category email toggles, the
|
||||||
header badge, the inbox panel, the toast surface, and the per-row
|
digest cadence dropdown, the quiet-hours editor, the watches
|
||||||
read-state machinery.
|
overview, and the per-user mute list is the natural follow-on.
|
||||||
|
Likely lives at `/settings/notifications` (the link Slice 6's
|
||||||
|
emails already point at).
|
||||||
|
- **The admin neighborhood.** §19.2's "Admin surfaces" candidate.
|
||||||
|
Role management, the §6.2 app-wide write-mute, the audit-log
|
||||||
|
viewer, the graduation-readiness queue. Topics 12 and 13 both
|
||||||
|
expanded the admin's repertoire without giving it a centralized
|
||||||
|
home; Slice 7 picks the framing.
|
||||||
|
- **Landing page polish.** Slice 1 stood up a minimal landing for
|
||||||
|
the unauthenticated path; §14 commits a richer shape — what the
|
||||||
|
framework is, why it exists, what the visitor's first read should
|
||||||
|
be, and the sign-in affordance.
|
||||||
|
- **The `/philosophy` route.** [`PHILOSOPHY.md`](../PHILOSOPHY.md)
|
||||||
|
rendered inline, reachable from the header on every page, so the
|
||||||
|
reader can return to the framing without leaving the app.
|
||||||
|
|
||||||
What Slice 6 owns specifically:
|
What Slice 7 does NOT own:
|
||||||
|
|
||||||
- **The producer fan-out.** Every `actions` row whose event maps to a
|
|
||||||
§15 signal produces zero-or-more `notifications` rows by joining
|
|
||||||
against `watches` and applying the §15.1 priority rules. The
|
|
||||||
fan-out lives as a small module that the bot wrapper invokes
|
|
||||||
inline after each write — same chokepoint shape Slice 1's
|
|
||||||
`_log` uses.
|
|
||||||
- **The §15.2 inbox.** `GET /api/notifications` with the
|
|
||||||
`unread` / `rfc_slug` / `category` / `bundled` filter chips,
|
|
||||||
`POST /api/notifications/<id>/read` for per-row marking,
|
|
||||||
`POST /api/notifications/read` for the bulk filter mark, and the
|
|
||||||
SSE `GET /api/notifications/stream` that backs the live badge.
|
|
||||||
- **The §15.3 surface.** The header badge counter (live via the SSE),
|
|
||||||
the toast on personal-direct events while the user is active, and
|
|
||||||
the ambient signal — a colored dot per row on the §7 catalog
|
|
||||||
pointing at watched RFCs with unseen activity.
|
|
||||||
- **The §15.4 email loop.** Per-category opt-in/out preferences on
|
|
||||||
the users table (already in the schema), the `/api/users/me/notification-preferences`
|
|
||||||
endpoints, the email-send adapter that routes a notification's
|
|
||||||
category through the user's category toggle, and the
|
|
||||||
`POST /api/webhooks/email-bounce` receiver that sets the global
|
|
||||||
opt-out. Plus the `GET /api/email/unsubscribe` signed-URL
|
|
||||||
one-click flow.
|
|
||||||
- **The §15.5 digest.** A scheduled-job that runs daily and weekly
|
|
||||||
to roll up unseen notifications into a single email, with the
|
|
||||||
`notification_digests` table tracking what was included so the
|
|
||||||
next digest skips what already shipped.
|
|
||||||
- **The §15.6 watch model.** Auto-watch on first interaction with
|
|
||||||
an RFC, the per-row state column (`watching` / `following` /
|
|
||||||
`muted`), the 90-day auto-decay for unset rows, and the explicit
|
|
||||||
`POST /api/rfcs/<slug>/watch` overrides.
|
|
||||||
- **The §15.7 unread mechanism.** Advance the `branch_chat_seen`
|
|
||||||
cursor on every branch read, reconcile inbox notifications to
|
|
||||||
read when their underlying surface is consumed.
|
|
||||||
- **The §15.8 do-not-disturb.** Quiet-hours config on the user, the
|
|
||||||
per-user notification mute list, the orthogonality vs §6.2's
|
|
||||||
app-wide write-mute.
|
|
||||||
|
|
||||||
What Slice 6 does NOT own:
|
|
||||||
|
|
||||||
- The §14 chrome polish (still Slice 7).
|
|
||||||
- The §12 30/90 branch-hygiene timers (still Slice 8).
|
- The §12 30/90 branch-hygiene timers (still Slice 8).
|
||||||
- The §16 deferred items.
|
- The §16 deferred items.
|
||||||
|
- New §15 capabilities — Slice 6 shipped the surface; settings UI
|
||||||
|
is exposure of what's already there, not new behavior.
|
||||||
|
|
||||||
The carryovers Slice 6 inherits — the existing `actions` audit log
|
The carryovers Slice 7 inherits — the existing §14 spec text, the
|
||||||
(every signal traces back to a row there per §15.9), the SSE
|
§17 endpoint set including Slice 6's settings endpoints, and the
|
||||||
machinery from Slices 2 and 5 (chat-stream and graduate-progress
|
React Router layout already in place.
|
||||||
respectively), and the §5 schema's notification tables (already
|
|
||||||
in place from Topic 13).
|
|
||||||
|
|
||||||
The §15 surface depends on the producers being in place; with
|
|
||||||
Slice 5 landing the last structural producer (graduation events,
|
|
||||||
specifically `graduate_complete` as a personal-direct event for
|
|
||||||
the proposer per §15.4), every signal a contributor needs to see
|
|
||||||
is now in the audit log waiting to be fanned out.
|
|
||||||
|
|
||||||
The next build session should read `SPEC.md`, `README.md`,
|
The next build session should read `SPEC.md`, `README.md`,
|
||||||
`docs/DEV.md`, and `SPEC.md`'s §19.1 and pick up Slice 6 cleanly
|
`docs/DEV.md`, and `SPEC.md`'s §19.1 and pick up Slice 7 cleanly
|
||||||
without re-briefing. The working agreement in §19.3 continues to
|
without re-briefing. The working agreement in §19.3 continues to
|
||||||
apply: implement the slice, correct the spec only where running
|
apply: implement the slice, correct the spec only where running
|
||||||
code reveals it was wrong at a structural level, accumulate new
|
code reveals it was wrong at a structural level, accumulate new
|
||||||
|
|||||||
@@ -1139,3 +1139,82 @@
|
|||||||
.branch-dropdown-item.pre-graduation .branch-meta {
|
.branch-dropdown-item.pre-graduation .branch-meta {
|
||||||
font-size: 10px; color: #9ca3af; margin-left: auto;
|
font-size: 10px; color: #9ca3af; margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- §15 / Slice 6: inbox, badge, toasts ---- */
|
||||||
|
|
||||||
|
.inbox-trigger {
|
||||||
|
position: relative; background: transparent; border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 16px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
.inbox-trigger:hover { background: #f9fafb; }
|
||||||
|
.inbox-trigger .badge {
|
||||||
|
position: absolute; top: -6px; right: -6px;
|
||||||
|
background: #dc2626; color: white; font-size: 10px;
|
||||||
|
border-radius: 999px; padding: 1px 5px; font-weight: 700;
|
||||||
|
min-width: 16px; text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.25);
|
||||||
|
display: flex; align-items: flex-start; justify-content: center;
|
||||||
|
padding-top: 60px; z-index: 100;
|
||||||
|
}
|
||||||
|
.inbox-panel {
|
||||||
|
background: white; border: 1px solid #e5e7eb; border-radius: 8px;
|
||||||
|
width: 720px; max-width: 90vw; max-height: 80vh;
|
||||||
|
display: flex; flex-direction: column; box-shadow: 0 12px 32px rgba(0,0,0,0.18);
|
||||||
|
}
|
||||||
|
.inbox-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.inbox-header h2 { margin: 0; font-size: 16px; }
|
||||||
|
.inbox-filters {
|
||||||
|
display: flex; gap: 8px; flex-wrap: wrap;
|
||||||
|
padding: 10px 16px; border-bottom: 1px solid #f3f4f6; align-items: center;
|
||||||
|
}
|
||||||
|
.inbox-filters .chip {
|
||||||
|
font-size: 12px; padding: 4px 8px; background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb; border-radius: 999px;
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
}
|
||||||
|
.inbox-filters .chip input[type=checkbox] { margin-right: 2px; }
|
||||||
|
.inbox-filters select.chip { padding: 4px 8px; }
|
||||||
|
|
||||||
|
.inbox-body { overflow-y: auto; flex: 1; }
|
||||||
|
.inbox-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.inbox-row { border-bottom: 1px solid #f3f4f6; }
|
||||||
|
.inbox-row.unread { background: #fffbeb; }
|
||||||
|
.inbox-row-link {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 10px 16px;
|
||||||
|
text-decoration: none; color: inherit;
|
||||||
|
}
|
||||||
|
.inbox-row-link:hover { background: #f9fafb; }
|
||||||
|
.inbox-cat {
|
||||||
|
font-size: 9px; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
padding: 2px 6px; border-radius: 4px; font-weight: 700;
|
||||||
|
}
|
||||||
|
.inbox-cat.cat-personal-direct { background: #fef3c7; color: #92400e; }
|
||||||
|
.inbox-cat.cat-structural { background: #dbeafe; color: #1e40af; }
|
||||||
|
.inbox-cat.cat-churn { background: #f3f4f6; color: #4b5563; }
|
||||||
|
.inbox-summary { flex: 1; font-size: 13px; }
|
||||||
|
.inbox-bundle-count {
|
||||||
|
font-size: 11px; color: #6b7280;
|
||||||
|
background: #f3f4f6; padding: 1px 6px; border-radius: 999px;
|
||||||
|
}
|
||||||
|
.inbox-when { font-size: 11px; color: #9ca3af; }
|
||||||
|
|
||||||
|
.toast-host {
|
||||||
|
position: fixed; right: 16px; bottom: 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 8px; z-index: 200;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
padding: 10px 14px; background: white; border: 1px solid #e5e7eb;
|
||||||
|
border-left: 4px solid #6b7280; border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.08); font-size: 13px;
|
||||||
|
max-width: 360px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.toast.cat-personal-direct { border-left-color: #d97706; }
|
||||||
|
.toast.cat-structural { border-left-color: #2563eb; }
|
||||||
|
.toast.cat-churn { border-left-color: #6b7280; }
|
||||||
|
|||||||
+53
-1
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Routes, Route, Link, useNavigate } from 'react-router-dom'
|
import { Routes, Route, Link, useNavigate } from 'react-router-dom'
|
||||||
import { getMe } from './api'
|
import { getMe, subscribeToNotifications } from './api'
|
||||||
import Catalog from './components/Catalog.jsx'
|
import Catalog from './components/Catalog.jsx'
|
||||||
|
import Inbox from './components/Inbox.jsx'
|
||||||
import RFCView from './components/RFCView.jsx'
|
import RFCView from './components/RFCView.jsx'
|
||||||
import PRView from './components/PRView.jsx'
|
import PRView from './components/PRView.jsx'
|
||||||
import ProposalView from './components/ProposalView.jsx'
|
import ProposalView from './components/ProposalView.jsx'
|
||||||
import ProposeModal from './components/ProposeModal.jsx'
|
import ProposeModal from './components/ProposeModal.jsx'
|
||||||
import Landing from './components/Landing.jsx'
|
import Landing from './components/Landing.jsx'
|
||||||
|
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -14,6 +16,9 @@ export default function App() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [proposeOpen, setProposeOpen] = useState(false)
|
const [proposeOpen, setProposeOpen] = useState(false)
|
||||||
const [catalogVersion, setCatalogVersion] = useState(0)
|
const [catalogVersion, setCatalogVersion] = useState(0)
|
||||||
|
const [inboxOpen, setInboxOpen] = useState(false)
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
|
const [inboxTick, setInboxTick] = useState(0)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -23,6 +28,39 @@ export default function App() {
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// §15.3 — subscribe to the live SSE stream for authenticated viewers
|
||||||
|
// so the badge counter and the toast surface stay in lockstep with
|
||||||
|
// the inbox. Tabs that miss an event because they were closed pick
|
||||||
|
// it up on next sign-in via the snapshot frame.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!me?.authenticated) return undefined
|
||||||
|
const close = subscribeToNotifications({
|
||||||
|
onSnapshot: payload => setUnreadCount(payload.unread_count || 0),
|
||||||
|
onNotification: payload => {
|
||||||
|
setUnreadCount(c => c + 1)
|
||||||
|
setInboxTick(t => t + 1)
|
||||||
|
// §15.3: personal-direct events get a toast even when the user
|
||||||
|
// isn't on the relevant view — they're the named subject.
|
||||||
|
// Churn never toasts; structural toasts only when it lands on
|
||||||
|
// a slug the user is currently viewing (URL match).
|
||||||
|
const isPersonal = payload.category === 'personal-direct'
|
||||||
|
const onCurrentSlug = payload.rfc_slug && window.location.pathname.includes(`/rfc/${payload.rfc_slug}`)
|
||||||
|
if (isPersonal || onCurrentSlug) {
|
||||||
|
showToast({
|
||||||
|
summary: payload.summary,
|
||||||
|
category: payload.category,
|
||||||
|
link: payload.rfc_slug ? `/rfc/${payload.rfc_slug}` : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRead: () => {
|
||||||
|
setUnreadCount(c => Math.max(0, c - 1))
|
||||||
|
setInboxTick(t => t + 1)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return close
|
||||||
|
}, [me?.authenticated])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="boot">Loading…</div>
|
return <div className="boot">Loading…</div>
|
||||||
}
|
}
|
||||||
@@ -38,6 +76,16 @@ export default function App() {
|
|||||||
<Link to="/">Wiggleverse RFCs</Link>
|
<Link to="/">Wiggleverse RFCs</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-right">
|
<div className="header-right">
|
||||||
|
<button
|
||||||
|
className="inbox-trigger"
|
||||||
|
onClick={() => setInboxOpen(o => !o)}
|
||||||
|
title="Notifications inbox (§15.2)"
|
||||||
|
>
|
||||||
|
<span aria-hidden>📮</span>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<span className="user-name">{me.user.display_name}</span>
|
<span className="user-name">{me.user.display_name}</span>
|
||||||
<span className={`user-role-badge role-${me.user.role}`}>{me.user.role}</span>
|
<span className={`user-role-badge role-${me.user.role}`}>{me.user.role}</span>
|
||||||
<a className="btn-link" href="/auth/logout">Sign out</a>
|
<a className="btn-link" href="/auth/logout">Sign out</a>
|
||||||
@@ -67,6 +115,10 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{inboxOpen && (
|
||||||
|
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
||||||
|
)}
|
||||||
|
<ToastHost />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -399,3 +399,100 @@ export async function streamChatTurn(slug, branch, threadId, { text, quote, mode
|
|||||||
return { assistantId, userMsgId }
|
return { assistantId, userMsgId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// §15 / Slice 6: notifications surface
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function listNotifications({ unread, rfcSlug, category, actorUserId, bundled } = {}) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (unread) params.set('unread', '1')
|
||||||
|
if (rfcSlug) params.set('rfc_slug', rfcSlug)
|
||||||
|
if (category) params.set('category', category)
|
||||||
|
if (actorUserId) params.set('actor_user_id', actorUserId)
|
||||||
|
if (bundled) params.set('bundled', '1')
|
||||||
|
const qs = params.toString()
|
||||||
|
return jsonOrThrow(await fetch(`/api/notifications${qs ? `?${qs}` : ''}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markNotificationRead(id) {
|
||||||
|
return jsonOrThrow(await fetch(`/api/notifications/${id}/read`, { method: 'POST' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markNotificationsReadByFilter(filter) {
|
||||||
|
return jsonOrThrow(await fetch('/api/notifications/read', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(filter || {}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWatches() {
|
||||||
|
return jsonOrThrow(await fetch('/api/watches'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setWatch(slug, state) {
|
||||||
|
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/watch`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ state }),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNotificationPreferences() {
|
||||||
|
return jsonOrThrow(await fetch('/api/users/me/notification-preferences'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setNotificationPreferences(prefs) {
|
||||||
|
return jsonOrThrow(await fetch('/api/users/me/notification-preferences', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(prefs),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuietHours() {
|
||||||
|
return jsonOrThrow(await fetch('/api/users/me/quiet-hours'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setQuietHours({ start, end, timezone } = {}) {
|
||||||
|
return jsonOrThrow(await fetch('/api/users/me/quiet-hours', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ start: start || null, end: end || null, timezone: timezone || null }),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function muteUser(userId) {
|
||||||
|
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unmuteUser(userId) {
|
||||||
|
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'DELETE' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function advanceChatSeen(slug, branch, lastSeenMessageId) {
|
||||||
|
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/branches/${branch}/chat-seen`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ last_seen_message_id: lastSeenMessageId || null }),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE subscription helper. Returns a close() function. The handler
|
||||||
|
// surface mirrors §15.3: a snapshot event on open, then per-notification
|
||||||
|
// `notification` events, plus `read` events when another tab marks a row.
|
||||||
|
export function subscribeToNotifications({ onSnapshot, onNotification, onRead, onError } = {}) {
|
||||||
|
const source = new EventSource('/api/notifications/stream')
|
||||||
|
source.addEventListener('snapshot', e => {
|
||||||
|
try { onSnapshot?.(JSON.parse(e.data)) } catch {}
|
||||||
|
})
|
||||||
|
source.addEventListener('notification', e => {
|
||||||
|
try { onNotification?.(JSON.parse(e.data)) } catch {}
|
||||||
|
})
|
||||||
|
source.addEventListener('read', e => {
|
||||||
|
try { onRead?.(JSON.parse(e.data)) } catch {}
|
||||||
|
})
|
||||||
|
source.onerror = err => { onError?.(err) }
|
||||||
|
return () => source.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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