Lane 1, Home's server side: campaign templates, the one-shot preset and one star, one wish (#727, fixes #743) #748

Merged
claude-bot merged 1 commit from build/lane1-home-server into main 2026-09-22 06:07:45 +00:00
Collaborator

Lane 1's second server change, behind Home (#727, series item 4; HOME-2's templates and one-shot, HOME-1's star and wish). Backend, docs/API.md, CHANGELOG.md; nothing in the frontend or the bot.

Templates on POST /api/campaigns (IS-222, IS-356): template is weekly (a reminder the day before and two hours before; sessions default to a fixed time), biweekly_vote (a reminder the day before; sessions default to a vote, closing after two days), or {"from_campaign_id"} (the settings of one of your own campaigns, nineteen settings-shaped columns enumerated in SETTINGS_COPIED_FROM_TEMPLATE, and nothing from its story, members, sessions, wiki or Discord; 403 unless you run or own it). No template creates a series: that needs a day and a time the form does not ask for. Presets apply first and any field you send wins, the same rule as the #743 fix.

The one-shot (IS-223): one_shot: {when, when_text} marks the campaign (is_one_shot, returned in the list and the detail), sets lighter reminders and no journals, and creates one session at once through the ordinary path, tentative at when if it parses, else proposed with "When, roughly: …" kept in its description. SessionCreate now lets a tentative session be created with no proposed times, which that needs; a zero-slot session was already reachable through PATCH, and confirming one is refused as before.

One star, one wish (IS-213, IS-216): GET /api/me/star-wish returns at most one prompt, the most recently completed session in the last 36 hours in a campaign you are in, that you have not answered or dismissed, and never for a GM about their own session. POST /api/sessions/{id}/feedback takes a line each and whether to sign it, once per person, inside the window; …/feedback/dismiss is "Not this time" and is never an error. GET /api/sessions/{id}/feedback (GM only) returns the lines with signed_by only when the person signed, and carries no user id at all, so an unsigned line cannot be attributed by joining against the member list. Feedback is deleted with the account, and named in the deletion audit like every other personal table.

Fixes #743: create_campaign now honours the eight fields CampaignCreate already declared and validated (guild_id, notification_channel_id, vote_notification_mode, vote_auto_close_hours, recap_email_enabled, journal_entry_enabled, narrative_style, transcription_language) instead of silently dropping them.

New column campaigns.default_scheduling_mode (nullable, written only by the templates for now, read by nothing yet, returned in the detail). Migration a6b7c8d9ebfc chained on f5a6b7c8d9eb, house style, verified up, down and up, and the schema diffed object for object against create_all.

Tests: 3,760 backend (55 new), ruff clean.

🤖 Generated with Claude Code

Lane 1's second server change, behind Home (#727, series item 4; HOME-2's templates and one-shot, HOME-1's star and wish). Backend, `docs/API.md`, `CHANGELOG.md`; nothing in the frontend or the bot. **Templates on `POST /api/campaigns`** (IS-222, IS-356): `template` is `weekly` (a reminder the day before and two hours before; sessions default to a fixed time), `biweekly_vote` (a reminder the day before; sessions default to a vote, closing after two days), or `{"from_campaign_id"}` (the settings of one of your own campaigns, nineteen settings-shaped columns enumerated in `SETTINGS_COPIED_FROM_TEMPLATE`, and nothing from its story, members, sessions, wiki or Discord; 403 unless you run or own it). No template creates a series: that needs a day and a time the form does not ask for. Presets apply first and any field you send wins, the same rule as the #743 fix. **The one-shot** (IS-223): `one_shot: {when, when_text}` marks the campaign (`is_one_shot`, returned in the list and the detail), sets lighter reminders and no journals, and creates one session at once through the ordinary path, tentative at `when` if it parses, else proposed with "When, roughly: …" kept in its description. `SessionCreate` now lets a tentative session be created with no proposed times, which that needs; a zero-slot session was already reachable through `PATCH`, and confirming one is refused as before. **One star, one wish** (IS-213, IS-216): `GET /api/me/star-wish` returns at most one prompt, the most recently completed session in the last 36 hours in a campaign you are in, that you have not answered or dismissed, and never for a GM about their own session. `POST /api/sessions/{id}/feedback` takes a line each and whether to sign it, once per person, inside the window; `…/feedback/dismiss` is "Not this time" and is never an error. `GET /api/sessions/{id}/feedback` (GM only) returns the lines with `signed_by` only when the person signed, and carries no user id at all, so an unsigned line cannot be attributed by joining against the member list. Feedback is deleted with the account, and named in the deletion audit like every other personal table. **Fixes #743**: `create_campaign` now honours the eight fields `CampaignCreate` already declared and validated (`guild_id`, `notification_channel_id`, `vote_notification_mode`, `vote_auto_close_hours`, `recap_email_enabled`, `journal_entry_enabled`, `narrative_style`, `transcription_language`) instead of silently dropping them. New column `campaigns.default_scheduling_mode` (nullable, written only by the templates for now, read by nothing yet, returned in the detail). Migration `a6b7c8d9ebfc` chained on `f5a6b7c8d9eb`, house style, verified up, down and up, and the schema diffed object for object against `create_all`. Tests: 3,760 backend (55 new), `ruff` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(backend): campaign templates, the one-shot preset and one star, one wish (#727)
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 59s
CI / Bot/backend version sync (pull_request) Successful in 24s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m34s
CI / Feedback site tests (pull_request) Successful in 42s
CI / Bot tests and audit (pull_request) Successful in 2m45s
CI / Frontend tests, audit, and build (pull_request) Successful in 3m16s
CI / Docker image build (pull_request) Successful in 4m27s
CI / Backend migration, tests, and audit (pull_request) Successful in 14m26s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 18m38s
f6048b6849
Lane 1's Home page, server side. Three surfaces from the drawing: the choice
that opens New campaign (IS-356), the templates behind it (IS-222, IS-223), and
the day-after prompt (IS-213, IS-216).

**Templates.** `POST /api/campaigns` takes an optional `template` — `weekly`,
`biweekly_vote`, or `{"from_campaign_id": ...}` — and an optional `one_shot`.
Both absent is today's behaviour exactly. The two are mutually exclusive (422):
"How do you want to start?" is a set of radio buttons, and there is no one-shot
weekly campaign. A preset is applied first and anything the request names
explicitly then overrides it.

A template never creates the recurring series, even the weekly one. A series
needs a day and a time, the form asks for neither, and inventing "Thursdays at
8" would be a guess a GM then has to find and undo. It is a Settings step.

`{"from_campaign_id": ...}` is settings only, from a campaign the caller runs
or owns (403 otherwise, 404 for one that is gone or in the trash). Nineteen
named columns copy; nothing else does. Not the name, not the story, not the
people — and pointedly not the webhook, the Discord ids, the share token or the
campaign's AI keys, because a "template" that silently pointed a new campaign
at another group's channel, or spent their API budget, would be a security bug
wearing a convenience feature's clothes. Presets and copied values are
deep-copied: two campaigns sharing one JSONB list is an in-place edit away from
rewriting each other.

**The one-shot** sets `campaigns.is_one_shot`, trims reminders to two hours,
turns the journal off, and creates its single session there and then — at the
time given, or with no time at all and what the person typed kept verbatim
("When, roughly: Sat 4 Oct, afternoon"). A free-text date is not a date, and
storing a guessed timestamp would put a reminder on the calendar for a moment
nobody chose. Both branches are `tentative`, so the session is `proposed` —
pencilled in, not fixed. It goes through `session_service.create_session`, so
it notifies the campaign like any other session; `SessionCreate` is relaxed to
let `tentative` — and only `tentative` — be created with no time.

**One star, one wish.** New `session_feedback` table, `GET /api/me/star-wish`,
`POST /api/sessions/{id}/feedback`, `.../feedback/dismiss` and the GM's
`GET /api/sessions/{id}/feedback`. Asked once, on Home, about one session
completed in the last 36 hours, never to the GM who ran it, and never chased —
there is no notification kind for it. "Not this time" is *stored*, because a
missing row and a declined prompt would otherwise be the same nothing and the
card would come back on the next page load. The GM's read carries no name
unless the person signed: "without names, unless you put yours in" is kept in
the service that builds the response, and the shape has no `user_id` for a
client to join against. Deleting an account deletes these rows outright rather
than repointing them at the tombstone.

**#743, on the way.** `CampaignCreate` declared `guild_id`,
`notification_channel_id`, `vote_notification_mode`, `vote_auto_close_hours`,
`recap_email_enabled`, `journal_entry_enabled`, `narrative_style` and
`transcription_language`, ran each one's validator — and then built the
campaign from six fields and dropped the other eight without a word. They are
honoured rather than removed: the validators were already there and are already
the same module-level functions `PATCH` runs, so there was nothing to duplicate
and nothing that can drift.

Migration `a6b7c8d9ebfc`, on `f5a6b7c8d9eb`. Verified upgrade → downgrade -1 →
upgrade against Postgres 16, and the DDL it produces was diffed against
`create_all`'s: identical, object for object. 55 new tests; the full backend
suite is 3760 passed, 13 skipped.

Fixes #743.

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