feat(webapp): web RSVP, post-session task statuses, and a "Needs you" dashboard (phase 4 of v4.3.0) #526

Merged
claude-bot merged 4 commits from feat/phase4-attention-surface into main 2026-09-06 04:41:15 +00:00
Contributor

Closes #370, closes #380, closes #368. Phase 4 of the v4.3.0 build order (#514): the attention surface. Two migrations, re-chained after lane A's so alembic has one head: f0ab1c2d3e4f → fa0b1c2d3e4f (session RSVP) → 0b1c2d3e4f5a (post_session_tasks), proven by applying all of them on a fresh Postgres 16 before the test files ran. No bot change, no contract bump.

#370 web RSVP. Reuses the row the Discord / reaction already writes (session_attendance), adding a nullable rsvp_status enum and rsvp_at, because attended is NOT NULL DEFAULT false and cannot tell "said no" from "never answered". GET /api/sessions/{id}/rsvps (one entry per member, null for unanswered, any member) and PUT /api/sessions/{id}/rsvp (members, for themselves only; open on confirmed sessions and on tentative-mode sessions still proposed; vote-mode proposed sessions are excluded because the slot vote already asks). The bot endpoint now also writes rsvp_status, so a Discord reaction shows in the web roster. Deliberately, the RSVP path does not write attended: that would assert attendance at a session that has not happened, and since bulk_insert_attendance_absent never overwrites an existing row it would silently disable the #114 recording auto-fill for everyone who RSVPed. No notification is published, because the Discord path publishes none either. UI: SessionRsvp.jsx, a Yes/Maybe/No segmented control, the party roster with an explicit "No answer" state, inline confirmation.

#380 the four invisible tasks. sessions.post_session_tasks JSONB, {task: {status, error, at}} for the storyline rebuild, journal entry, title suggestions and recap email. Every write is one COALESCE(post_session_tasks,'{}') || :patch statement because the four tasks run concurrently in separate workers and a read-modify-write would lose someone's failure. Queued marks are written at fan-out from both call sites via summary_events.fanout_task_names(), so the fan-out list and the status map cannot drift (tested); a refused enqueue is failed, not queued. Absent, failed and skipped are distinct. UI: PostSessionChecklist.jsx with all six rows; failure reasons GM-only, the state word for everyone; the recap row reads Queued / Sent / Failed.

#368 "Needs you". GET /api/me/attention returns five kinds (in_progress, vote, rsvp, approve_transcript, wiki_suggestions) in six round trips regardless of campaign count, with the "already answered?" tests as correlated EXISTS subqueries; GM-only kinds are skipped when the user GMs nothing; archived and trashed campaigns contribute nothing. NeedsYou.jsx renders above the campaign list only when there is something, deep-links each row, and takes its labels from vocabulary.js. The countdown line is now a link to the session, which meant the campaign card became a container with the campaign link inside it.

Backend full suite 2415 passed / 13 skipped before the rebase and the two new test files (39 tests) pass on the re-chained schema; frontend 555 passed (+32); ruff clean. Follow-ups filed: #524 (count_bot_proposals ignores deleted_at), #525 (RSVP "no" into the at-risk evaluation).

🤖 Generated with Claude Code

Closes #370, closes #380, closes #368. Phase 4 of the v4.3.0 build order (#514): the attention surface. **Two migrations**, re-chained after lane A's so alembic has one head: `f0ab1c2d3e4f → fa0b1c2d3e4f (session RSVP) → 0b1c2d3e4f5a (post_session_tasks)`, proven by applying all of them on a fresh Postgres 16 before the test files ran. No bot change, no contract bump. **#370 web RSVP.** Reuses the row the Discord ✅/❌ reaction already writes (`session_attendance`), adding a nullable `rsvp_status` enum and `rsvp_at`, because `attended` is `NOT NULL DEFAULT false` and cannot tell "said no" from "never answered". `GET /api/sessions/{id}/rsvps` (one entry per member, `null` for unanswered, any member) and `PUT /api/sessions/{id}/rsvp` (members, for themselves only; open on confirmed sessions and on tentative-mode sessions still proposed; vote-mode proposed sessions are excluded because the slot vote already asks). The bot endpoint now also writes `rsvp_status`, so a Discord reaction shows in the web roster. Deliberately, the RSVP path does **not** write `attended`: that would assert attendance at a session that has not happened, and since `bulk_insert_attendance_absent` never overwrites an existing row it would silently disable the #114 recording auto-fill for everyone who RSVPed. No notification is published, because the Discord path publishes none either. UI: `SessionRsvp.jsx`, a Yes/Maybe/No segmented control, the party roster with an explicit "No answer" state, inline confirmation. **#380 the four invisible tasks.** `sessions.post_session_tasks` JSONB, `{task: {status, error, at}}` for the storyline rebuild, journal entry, title suggestions and recap email. Every write is one `COALESCE(post_session_tasks,'{}') || :patch` statement because the four tasks run concurrently in separate workers and a read-modify-write would lose someone's failure. Queued marks are written at fan-out from both call sites via `summary_events.fanout_task_names()`, so the fan-out list and the status map cannot drift (tested); a refused enqueue is `failed`, not `queued`. Absent, failed and skipped are distinct. UI: `PostSessionChecklist.jsx` with all six rows; failure reasons GM-only, the state word for everyone; the recap row reads Queued / Sent / Failed. **#368 "Needs you".** `GET /api/me/attention` returns five kinds (`in_progress`, `vote`, `rsvp`, `approve_transcript`, `wiki_suggestions`) in six round trips regardless of campaign count, with the "already answered?" tests as correlated `EXISTS` subqueries; GM-only kinds are skipped when the user GMs nothing; archived and trashed campaigns contribute nothing. `NeedsYou.jsx` renders above the campaign list only when there is something, deep-links each row, and takes its labels from `vocabulary.js`. The countdown line is now a link to the session, which meant the campaign card became a container with the campaign link inside it. Backend full suite 2415 passed / 13 skipped before the rebase and the two new test files (39 tests) pass on the re-chained schema; frontend 555 passed (+32); ruff clean. Follow-ups filed: #524 (`count_bot_proposals` ignores `deleted_at`), #525 (RSVP "no" into the at-risk evaluation). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
RSVP is a named step of the session lifecycle and the web app had no
control for it. The only attendance controls were GM-only and gated to
completed sessions, and the Discord side was a / reaction on the
session_confirmed embed — so a group that is not Discord-native, or a
player who never linked an account, could not RSVP at all, and nobody
could see the roster.

`session_attendance` gains a nullable `rsvp_status` (+ `rsvp_at`).
Nullable is the point: the existing `attended` boolean cannot tell "said
no" from "has not answered", which is the state the roster and the
dashboard's attention list both have to show.

`attended` is deliberately left alone by the RSVP path. It records who
turned up, which is a different claim from who said they would; writing
"yes" through to it would assert something untrue of a session that has
not happened, and would silently disable the recording attendance
auto-fill (#114), whose invariant is that an existing row always wins.

The bot's `PUT /api/bot/sessions/{id}/attendance/{discord_id}` now writes
`rsvp_status` alongside its unchanged effect on `attended`, so a reaction
in Discord and a click in the browser land on the same row. Request and
response shapes are identical, so no bot code changes and no
BOT_CONTRACT_VERSION bump.

No notification is published on a web RSVP: the Discord path publishes
none either, so there was no existing event to mirror.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
One summary event fans out six tasks. Audio processing and lore proposals
each write a status column and appear on the session page; the campaign
storyline rebuild, the journal entry, the next-session title suggestions
and the party recap email wrote nothing anywhere. An unconfigured LLM, a
broker that dropped the enqueue and an SMTP host that refused every
address all looked identical to a task that had simply not run yet.

`sessions.post_session_tasks` (JSONB, nullable, migration 0b1c2d3e4f5a)
holds `{task: {status, error, at}}` for those four. One column rather
than eight scalars: they are four instances of one shape, read together
or not at all, and a fifth fan-out task should cost a dict key.

`app/services/post_session_tasks.py` owns every write and does it as a
single `post_session_tasks = COALESCE(...,'{}') || :patch` statement.
The four tasks run concurrently in separate workers, and a
read-modify-write from four processes loses updates — the one it loses
being somebody's failure.

Absent is not failed. A NULL column (a fan-out that predates this) and a
missing key (the recap email on a summary *edit*, which must never
re-send) both read as "no record", and `skipped` — journal entries off,
campaign archived, storyline hand-edited — is distinct from `failed`.
A refused enqueue is recorded as failed rather than queued, for the
reason #413 gave for lore generation.

`generate_session_title_suggestions` gains an optional `session_id`: it
generates for a campaign, but a GM looks for "did this run?" on the
session whose summary set it off. The manual regenerate endpoint omits
it and records nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The dashboard rendered a campaign list and nothing else. Everything
actually waiting on you lived one or two clicks inside a campaign — a
vote you had not cast, a session you had not RSVPed to, a transcript
sitting in `ready` for a GM to approve, suggested wiki updates nobody had
reviewed, a recording running right now — and the only hint any of it
existed was a non-clickable "Next session in 3d 4h".

`GET /api/me/attention` returns all five kinds in one response.
An aggregate rather than five client-side calls per campaign: the page
has to know whether *anything* is pending before it can decide to render
a section at all, and asking N×5 questions to find out is how a landing
page makes twenty requests to display nothing.

One query per kind, six round trips regardless of how many campaigns the
user is in. Membership is fetched once; every kind is scoped by
`campaign_id IN (...)` against it, and "has this user already answered?"
is a correlated EXISTS rather than a second pass in Python. The two
GM-only kinds are skipped entirely when the user GMs nothing.

Archived and trashed campaigns contribute nothing — archiving is how a
group says it has stopped playing (#479), and a dashboard that kept
nagging about its open votes would undo that.

The section is absent, not empty, when nothing is pending: a block that
is there every day is a block people stop reading. Kind labels come from
`vocabulary.js`, so a suggested wiki update is called the same thing here
as on the page it links to (#379).

The campaign card is now a container with the campaign link inside it
rather than being one link itself, because the countdown line has become
its own link to the session and an anchor cannot nest in an anchor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
chore(alembic): chain the RSVP migration after the delivery-status one (#370, #369)
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 27s
CI / Bot/backend version sync (pull_request) Successful in 33s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m9s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m46s
CI / Bot tests and audit (pull_request) Successful in 1m49s
CI / Docker image build (pull_request) Successful in 2m5s
CI / Backend migration, tests, and audit (pull_request) Successful in 9m3s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 17m51s
421021d978
Both phase-4 and phase-5 lanes branched from e9fa0b1c2d3e; lane A merged first,
so fa0b1c2d3e4f now follows f0ab1c2d3e4f and alembic has one head again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude-bot scheduled this pull request to auto merge when all checks succeed 2026-09-06 04:22:42 +00:00
claude-bot deleted branch feat/phase4-attention-surface 2026-09-06 04:41:15 +00:00
Sign in to join this conversation.
No description provided.