Lane 1, the server side: notifications, campaign search and the live endpoint (#727, #655) #744

Merged
claude-bot merged 2 commits from build/lane1-notifications into main 2026-09-22 05:09:40 +00:00
Collaborator

Lane 1's server PR (series item 1 on #727; #655's core). Backend, docs/API.md, and one string in the shell's search client.

Notifications. A per-person history of what happened, with recipients worked out on the server and text built when it is read. notifications stores a recipient, a campaign, a kind, and a reference to its subject, never text; render() loads the subject with the reader's current permissions and says "No longer available" when they may no longer see it (left the campaign, a GM-only kind for a player, the subject gone). Nobody is told about their own action. Kinds and where they fire: session_confirmed (RSVP folded in: one row for one moment, linking to the page the RSVP control is on), vote_opened (a vote-mode session's creation, a reopened vote, a changed slate to those yet to vote), session_scheduled (a tentative session), session_cancelled, transcript_ready (GMs), summary_ready (members, first time only), wiki_suggestions (GMs, one row per batch), member_joined (GMs, from an invite, an approval or being added), ownership_offer and ownership_answered. calendar_invite is defined but not fired, since the same confirm already rings the bell. Every hook sits beside the existing bot post and email, inside a savepoint, so a notification failing never breaks the thing it is about; and every bot emit returns early without a guild, which is exactly the gap these rows close.

Delivery. In-app always. Email through the existing SMTP path, carrying a line and a link and never the content, at most once per notification per channel (the delivery row is claimed before the send). Settings are a grid of category (sessions, discussions, wiki, GM tasks) against channel (email; Discord DM listed only when the bot is configured and not delivered yet), plus a mute per campaign. Nobody's current email changes: the migration carries recap_email_opt_in = true over as the (sessions, email) row, the recap email itself is untouched and now reads that one setting, and tests/test_recap_email.py proves the pre-#655 shape still gets its mail. A mute also silences the recap email; no existing user has one.

The API. GET /api/me/notifications (keyset paging, rendered for the reader), GET /api/me/notifications/unread, POST /api/me/notifications/{id}/read, POST /api/me/notifications/read-all, GET/PUT /api/me/notification-settings, PUT/DELETE /api/me/notification-settings/mutes/{campaign_id}, GET /api/campaigns/{id}/search-all, GET /api/campaigns/{id}/live, GET /api/meta, and pronouns on GET/PATCH /api/me. Documented in docs/API.md.

Search (SH-9). One box, results tagged by kind: sessions (title and summary), people (members and their characters, aliases included), wiki (title, body, aliases; a hit found only in GM material is marked gm and returned to GMs alone), rules (loaded content packs; href null until there is a page), and with transcripts=1 for a GM the transcript segments with the speaker and timestamp. Two characters minimum ("One more letter."). The path is /search-all because /search is the old campaign page's session-memory search, which returns a bare array; two shapes cannot share a path without breaking that page on main, so this one moves to /search when lane 3 rebuilds the page. The shell's client points at it.

Live (SH-6). The campaign's session in progress and its recording state from the cached bot status, cache only: recording (heard within 90 s), stale, none. The second commit makes stale reachable: the bot's status key lives 15 s, so a crash was indistinguishable from a clean stop and /live said none over a session nobody was capturing, the one answer IS-001 forbids. The backend now keeps a ten-minute memo of the last active payload it saw, refreshed by every read that sees one and deleted by an explicit stop; the live key gone and the memo present is stale, with last_heard_at. The residual gap, documented: a stop published while nobody has the app open can read as stale for a few minutes, chosen over the opposite error.

Findings for the owner (also on #727): there is no session numbering anywhere in Quest Board, so where the prototype says "Session 14" the app says the session's title; a real ordinal is its own decision. campaigns.owner_id is the billing anchor and stays; nothing under /api/bot/* changed, so no contract bump.

Migration f5a6b7c8d9eb (raw SQL, TEXT + CHECK, chained on the true single head e4f5a6b7c8da), verified up, down and up against Postgres and matching the models' constraint names. session_number does not exist; users.pronouns does now.

Tests: 3,684 backend (98 new), ruff clean; the frontend's src/api suite with a new search.test.js.

🤖 Generated with Claude Code

Lane 1's server PR (series item 1 on #727; #655's core). Backend, `docs/API.md`, and one string in the shell's search client. **Notifications.** A per-person history of what happened, with recipients worked out on the server and text built when it is read. `notifications` stores a recipient, a campaign, a kind, and a *reference* to its subject, never text; `render()` loads the subject with the reader's current permissions and says "No longer available" when they may no longer see it (left the campaign, a GM-only kind for a player, the subject gone). Nobody is told about their own action. Kinds and where they fire: `session_confirmed` (RSVP folded in: one row for one moment, linking to the page the RSVP control is on), `vote_opened` (a vote-mode session's creation, a reopened vote, a changed slate to those yet to vote), `session_scheduled` (a tentative session), `session_cancelled`, `transcript_ready` (GMs), `summary_ready` (members, first time only), `wiki_suggestions` (GMs, one row per batch), `member_joined` (GMs, from an invite, an approval or being added), `ownership_offer` and `ownership_answered`. `calendar_invite` is defined but not fired, since the same confirm already rings the bell. Every hook sits beside the existing bot post and email, inside a savepoint, so a notification failing never breaks the thing it is about; and every bot emit returns early without a guild, which is exactly the gap these rows close. **Delivery.** In-app always. Email through the existing SMTP path, carrying a line and a link and never the content, at most once per notification per channel (the delivery row is claimed before the send). Settings are a grid of category (sessions, discussions, wiki, GM tasks) against channel (email; Discord DM listed only when the bot is configured and not delivered yet), plus a mute per campaign. **Nobody's current email changes**: the migration carries `recap_email_opt_in = true` over as the `(sessions, email)` row, the recap email itself is untouched and now reads that one setting, and `tests/test_recap_email.py` proves the pre-#655 shape still gets its mail. A mute also silences the recap email; no existing user has one. **The API.** `GET /api/me/notifications` (keyset paging, rendered for the reader), `GET /api/me/notifications/unread`, `POST /api/me/notifications/{id}/read`, `POST /api/me/notifications/read-all`, `GET`/`PUT /api/me/notification-settings`, `PUT`/`DELETE /api/me/notification-settings/mutes/{campaign_id}`, `GET /api/campaigns/{id}/search-all`, `GET /api/campaigns/{id}/live`, `GET /api/meta`, and `pronouns` on `GET`/`PATCH /api/me`. Documented in `docs/API.md`. **Search (SH-9).** One box, results tagged by kind: sessions (title and summary), people (members and their characters, aliases included), wiki (title, body, aliases; a hit found only in GM material is marked `gm` and returned to GMs alone), rules (loaded content packs; `href` null until there is a page), and with `transcripts=1` for a GM the transcript segments with the speaker and timestamp. Two characters minimum ("One more letter."). **The path is `/search-all`** because `/search` is the old campaign page's session-memory search, which returns a bare array; two shapes cannot share a path without breaking that page on main, so this one moves to `/search` when lane 3 rebuilds the page. The shell's client points at it. **Live (SH-6).** The campaign's session in progress and its recording state from the cached bot status, cache only: `recording` (heard within 90 s), `stale`, `none`. The second commit makes `stale` reachable: the bot's status key lives 15 s, so a crash was indistinguishable from a clean stop and `/live` said `none` over a session nobody was capturing, the one answer IS-001 forbids. The backend now keeps a ten-minute memo of the last *active* payload it saw, refreshed by every read that sees one and deleted by an explicit stop; the live key gone and the memo present is `stale`, with `last_heard_at`. The residual gap, documented: a stop published while nobody has the app open can read as stale for a few minutes, chosen over the opposite error. **Findings for the owner** (also on #727): there is no session numbering anywhere in Quest Board, so where the prototype says "Session 14" the app says the session's title; a real ordinal is its own decision. `campaigns.owner_id` is the billing anchor and stays; nothing under `/api/bot/*` changed, so no contract bump. Migration `f5a6b7c8d9eb` (raw SQL, TEXT + CHECK, chained on the true single head `e4f5a6b7c8da`), verified up, down and up against Postgres and matching the models' constraint names. `session_number` does not exist; `users.pronouns` does now. Tests: 3,684 backend (98 new), `ruff` clean; the frontend's `src/api` suite with a new `search.test.js`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Lane 1's server side. Quest Board could already tell people things, but only
through Discord: every bot message returns early without a guild_id, so a table
that never connected one heard nothing at all, and the "Needs you" aggregate
(#368) only ever said what was *still* pending, never that something had
happened. This is the record.

Notifications. Four tables — notifications, notification_settings,
campaign_mutes, notification_deliveries — and one idea: the row is the record,
the sentence is not. A row stores a reference to its subject, never rendered
text, and notification_service.render builds the sentence at read time against
the reader's permissions as they are *now*. A notification about a session in a
campaign somebody has since left reads "No longer available" instead of leaking
the title it had. Storing the sentence would freeze the permission check along
with the wording, and the permission check is the half that must not freeze.

Twelve kinds; ten of them fire today, beside the existing Discord and email
fan-out and never instead of it. campaign_members is the only place the audience
rule is written down, and a test proves a player never receives a GM-only kind
and a member of another campaign never receives anything. notify does its
inserts in a SAVEPOINT, so a notification can never roll back the event that
caused it.

Nobody's current email changes. The one per-user email opt-in Quest Board had —
users.recap_email_opt_in — becomes the (sessions, email) cell of the new grid.
The migration writes an explicit row for everyone who had opted in, and
notification_settings_service falls back to the column when no row exists, which
is also what makes a create_all schema behave like a migrated one.
send_recap_email now reads that cell through the one helper rather than the
column, so there is a single truth about who wants session mail; the column is
kept and mirrored on write until the old Profile page goes. Email is off by
default everywhere else, an email is only ever a short line and a link, and
summary_ready is deliberately never emailed here because the recap *is* that
kind's email adapter. Discord DMs are listed but reported unavailable: the
adapter is an interface and nothing delivers one yet.

Campaign search (GET /api/campaigns/{id}/search-all): sessions, the people at
the table and their characters, the wiki and the loaded rules, each queried
through the same code that serves its own page — the wiki through
lore_service.list_lore_entries, the rules through reference.search_service — so
a search can never be the place those rules drift. Transcripts are opt-in and
GM-only. The name is transitional: /search is taken by the older session-memory
search that today's Campaign page still calls, and two response shapes cannot
share a path.

GET /api/campaigns/{id}/live reads the bot's cached status and never calls the
bot, so the live bar is cheap to poll. Also users.pronouns (#653) on GET and
PATCH /api/me, and GET /api/meta for the footer.

Migration f5a6b7c8d9eb on e4f5a6b7c8da — raw SQL, TEXT + CHECK, one head.
3674 backend tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix(backend): make the live bar's "stale" reachable, and point the shell at search-all (#727)
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 53s
CI / Docker image build (pull_request) Successful in 46s
CI / Bot/backend version sync (pull_request) Successful in 46s
CI / Backend lint (ruff) (pull_request) Successful in 49s
CI / Bot tests and audit (pull_request) Successful in 1m51s
CI / Feedback site tests (pull_request) Successful in 1m8s
CI / Frontend tests, audit, and build (pull_request) Successful in 2m44s
CI / Backend migration, tests, and audit (pull_request) Successful in 8m47s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 16m43s
5e8c69d9be
Two follow-ups to the lane 1 server PR.

A bot that dies mid-recording must not read as "not being recorded" (IS-001),
and until now it did. The bot's status key lives fifteen seconds and is
republished every two, so fifteen seconds after a crash there is nothing left
and a crash is indistinguishable from a clean stop — /live said `none` over a
session nobody was capturing, and `stale` was unreachable in practice.

The backend now keeps its own memo, `qb:bot:recording:last_active:<guild_id>`,
with a ten-minute TTL. It is written wherever the backend sees a payload —
`get_cached_recording_status`, `get_cached_recording_status_with_age`, and the
pub/sub subscriber, which is what keeps it warm while a GM watches the
recording dashboard. An active payload refreshes it; an inactive one deletes
it, so a clean stop does not read as a crash; nothing at all leaves it alone,
because absence is exactly the case it exists for. /live reads the live key
first and keeps the 90-second rule for a payload that is present but old, and
falls back to the memo only when the key is gone entirely.

The memo is not part of the bot contract — the bot neither writes nor reads it
— and it can only record what the backend actually saw. One residual gap, and
it errs the safe way: a stop published while nobody has the app open expires
unseen, so a clean stop can read as `stale` for a few minutes.

Also: the shell's search client now asks `/api/campaigns/{id}/search-all`.
`/search` is the old campaign page's session-memory search, which returns a
bare array and is still what that page calls; the two shapes cannot share a
path until lane 3 rebuilds it. `src/api/search.test.js` pins the path, because
a one-character slip there reaches a real endpoint and gets a shape the caller
cannot read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.