feat(events): the public calendar, event pages and participation history (Phase 14a) #196

Merged
whitlocktech merged 2 commits from feature/events-p14a-public-surface into edge 2026-09-08 17:04:55 +00:00
Member

Events Phase 14a. One of two: website, docs (docs#224), both onto edge. Spec of record: docs/website/EVENTS.md §I and § API surface.

Phase 14 was split into 14a and 14b (org lead), on the repo line: the app cannot be walked against a page that does not exist yet, so 14b's emulator walk is only meaningful once this has merged. The same argument 12a/12b was split on. MODULE_API_VERSION is untouched — nothing about the module contract changed.

Four routes, none moved: GET /public/events, /public/events/:slug, /public/events/series/:slug, and GET /player/events/history.

listed is announcement, not permission

The one decision that changed the shape of the work. Publishing is what makes a definition runnable, so with state alone a surprise invasion would have to be advertised a fortnight in advance in order to be allowed to happen. listed is a column defaulting to 1, a switch in Phase 13's editor, and three predicates in SQL — filtering after the read works exactly as well right up until the first caller that forgets. An unlisted event still schedules, still runs and is still on the admin calendar; a draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed.

It is deliberately not a second answer to what state answers, which is the trap findSchedulable's own comment warns about: state says schedulable, listed says announced.

The public shapes are a projection, and the projection is the security boundary

Every other reader of these tables is staff. These are read by nobody at all, so nothing is spread and a field reaches a public entry because a line in eventPublic.model.js put it there. The day somebody adds a column to event_runs — a claim token, an operator's note, a last error — a { ...run } would publish it in the release after the one anybody reviewed. The test fixture carries health, cleanup_status, claimed_by and last_error on purpose, and one test asserts the exact key set of a calendar entry.

Absent by construction: the spec (a live run carries the label of its phase, resolved from the version it pinned, and nothing else), health/cleanup/claims/errors, and member_key — module-opaque, so core cannot say what publishing one would disclose.

failed and missed are published as cancelled and paused as live: to a visitor the first three are one event, and the difference between them is about the deployment rather than about the event.

eventUrl, which this codebase has owed since Phase 10

The six public triggers gained it (version 1 → 2), and notify.event-started gained the button at seedVersion 2. It was withheld until there was a page because news.post had already paid for that mistake once — previewing a link in the template editor that was dead in every mail it sent. It carries ?run=: the page lives at the definition's slug so a weekly event has one address that survives a retitle, while every one of those triggers is about one occurrence. It is optional, and email.button drops itself when its url interpolates to nothing, so an unlisted event still mails correctly with the block absent rather than degraded. run.failed gains nothing — an admin reading that the machinery broke wants the console.

Core also publishes an events capability in the version block. Same word as a module's on /public/modules, separate list because core is not a module — and the value is in what is absent: an older backend omits the key, a distinction probing /public/events cannot make.

Three defects, and the live walk found all three

Both suites were green before the walk and after each fix. Two were one mistake in two files — a split reading a status where it should read a clock:

  • The calendar told a visitor an event four days away "DID NOT HAPPEN". It had been cancelled, not missed. The word now follows the tense: future → Cancelled, past → Did not happen, which is also the honest word for the failed and missed runs folded in.
  • That same future occurrence was filed under "Previously" on the event page. upcoming now splits on the instant, which is what the model's own comment already claimed. A cancelled occurrence still appears under what is coming, because "next Friday is off" is exactly what somebody checking a calendar came to find out. next stays narrower and skips it.
  • Staff could not reach their own participation history at all. /player/events/history is behind requireAuth alone and self-scoped, but RequirePlayer sends anyone who is not a player out of /account — so /account/events redirected the reviewing admin to the dashboard. Engagement Phase 7 hit this exact wall with the inbox; eventHistoryPath joins inboxPath and notificationSettingsPath in notificationPaths.js, and the staff path is /admin/events/mine.

A fourth thing the walk exposed: the eventAnnounce fixture had no slug, state or listed, so eventUrl answered undefined in every test in that file and the new emitter code was exercised by none of them.

A swagger trap worth knowing

A backtick inside a #swagger.parameters annotation is rewritten as a quote and swagger-autogen then drops the whole annotation with a syntax error rather than failing the build. #swagger.summary and #swagger.description are plain strings and unaffected. Noted in player/events.router.js.

Checks

Server npm test: 2140 tests, 2051 pass, 0 fail (89 DB-skipped) — the new eventPublic.test.js (23) plus 6 in eventAnnounce. Client npm test: 396 pass, 0 fail, 16 new. The client builds. routes:manifest, routes.guards.json, engagement:manifest and swagger regenerated — four routes added, none moved, and the guards manifest shows siteMode on the three public reads and noindex, requireAuth on the player one.

Walked in a browser against the local stack, end to end and with two accounts. The calendar, an event page with a published results table, an arc, and participation history as an admin (/admin/events/mine) and as a player (/account/events) — the second showing only that account's own row, which is the self-scoping proved rather than asserted. The listed switch was toggled off and on through the editor, and each time all three public surfaces agreed: gone from the calendar, 404 on its page, and dropped from its arc; then all three back.

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

Events Phase 14a. One of **two**: `website`, `docs` (docs#224), both onto `edge`. Spec of record: [`docs/website/EVENTS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/224) §I and § API surface. Phase 14 was **split into 14a and 14b** (org lead), on the repo line: the app cannot be walked against a page that does not exist yet, so 14b's emulator walk is only meaningful once this has merged. The same argument 12a/12b was split on. **`MODULE_API_VERSION` is untouched** — nothing about the module contract changed. Four routes, none moved: `GET /public/events`, `/public/events/:slug`, `/public/events/series/:slug`, and `GET /player/events/history`. ## `listed` is announcement, not permission The one decision that changed the shape of the work. **Publishing is what makes a definition runnable**, so with `state` alone a surprise invasion would have to be advertised a fortnight in advance in order to be allowed to happen. `listed` is a column defaulting to 1, a switch in Phase 13's editor, and three predicates **in SQL** — filtering after the read works exactly as well right up until the first caller that forgets. An unlisted event still schedules, still runs and is still on the admin calendar; a draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. It is deliberately not a second answer to what `state` answers, which is the trap `findSchedulable`'s own comment warns about: `state` says schedulable, `listed` says announced. ## The public shapes are a projection, and the projection is the security boundary Every other reader of these tables is staff. These are read by nobody at all, so **nothing is spread** and a field reaches a public entry because a line in `eventPublic.model.js` put it there. The day somebody adds a column to `event_runs` — a claim token, an operator's note, a last error — a `{ ...run }` would publish it in the release after the one anybody reviewed. The test fixture carries `health`, `cleanup_status`, `claimed_by` and `last_error` **on purpose**, and one test asserts the exact key set of a calendar entry. Absent by construction: the **spec** (a live run carries the *label* of its phase, resolved from the version it pinned, and nothing else), **health/cleanup/claims/errors**, and **`member_key`** — module-opaque, so core cannot say what publishing one would disclose. `failed` and `missed` are published as `cancelled` and `paused` as `live`: to a visitor the first three are one event, and the difference between them is about the deployment rather than about the event. ## `eventUrl`, which this codebase has owed since Phase 10 The six public triggers gained it (**version 1 → 2**), and `notify.event-started` gained the button at `seedVersion` 2. It was withheld until there was a page because `news.post` had already paid for that mistake once — previewing a link in the template editor that was dead in every mail it sent. It carries **`?run=`**: the page lives at the definition's slug so a weekly event has one address that survives a retitle, while every one of those triggers is about one *occurrence*. It is **optional**, and `email.button` drops itself when its url interpolates to nothing, so an unlisted event still mails correctly with the block absent rather than degraded. `run.failed` gains nothing — an admin reading that the machinery broke wants the console. Core also publishes an **`events` capability** in the `version` block. Same word as a module's on `/public/modules`, separate list because core is not a module — and the value is in what is *absent*: an older backend omits the key, a distinction probing `/public/events` cannot make. ## Three defects, and the live walk found all three Both suites were green before the walk and after each fix. **Two were one mistake in two files** — a split reading a status where it should read a clock: - **The calendar told a visitor an event four days away "DID NOT HAPPEN".** It had been cancelled, not missed. The word now follows the tense: future → *Cancelled*, past → *Did not happen*, which is also the honest word for the failed and missed runs folded in. - **That same future occurrence was filed under "Previously"** on the event page. `upcoming` now splits on the instant, which is what the model's own comment already claimed. A cancelled occurrence still appears under what is coming, because *"next Friday is off"* is exactly what somebody checking a calendar came to find out. `next` stays narrower and skips it. - **Staff could not reach their own participation history at all.** `/player/events/history` is behind `requireAuth` alone and self-scoped, but `RequirePlayer` sends anyone who is not a `player` out of `/account` — so `/account/events` redirected the reviewing admin to the dashboard. Engagement Phase 7 hit this exact wall with the inbox; `eventHistoryPath` joins `inboxPath` and `notificationSettingsPath` in `notificationPaths.js`, and the staff path is `/admin/events/mine`. A fourth thing the walk exposed: **the `eventAnnounce` fixture had no slug, state or `listed`**, so `eventUrl` answered `undefined` in every test in that file and the new emitter code was exercised by none of them. ## A swagger trap worth knowing A backtick inside a `#swagger.parameters` annotation is rewritten as a quote and swagger-autogen then **drops the whole annotation** with a syntax error rather than failing the build. `#swagger.summary` and `#swagger.description` are plain strings and unaffected. Noted in `player/events.router.js`. ## Checks Server `npm test`: **2140 tests, 2051 pass, 0 fail** (89 DB-skipped) — the new `eventPublic.test.js` (23) plus 6 in `eventAnnounce`. Client `npm test`: **396 pass, 0 fail**, 16 new. The client builds. `routes:manifest`, `routes.guards.json`, `engagement:manifest` and `swagger` regenerated — **four routes added, none moved**, and the guards manifest shows `siteMode` on the three public reads and `noindex, requireAuth` on the player one. **Walked in a browser against the local stack**, end to end and with two accounts. The calendar, an event page with a published results table, an arc, and participation history as an **admin** (`/admin/events/mine`) and as a **player** (`/account/events`) — the second showing only that account's own row, which is the self-scoping proved rather than asserted. The `listed` switch was toggled off and on **through the editor**, and each time all three public surfaces agreed: gone from the calendar, 404 on its page, and dropped from its arc; then all three back. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 2 commits 2026-09-08 17:00:24 +00:00
The anonymous surface an event was always for: GET /public/events,
/public/events/:slug and /public/events/series/:slug, plus
GET /player/events/history, and the four screens over them.

Four org-lead decisions taken up front: split Phase 14 into 14a (website)
and 14b (the app); add a `listed` flag rather than letting `state` mean both
schedulable and announced; put the `events` capability string in the version
block rather than publishing core as a pseudo-module; and drop "venue" from
the spec rather than adding a field nothing had ever built.

`listed` is announcement, not permission. Publishing is what makes a
definition runnable, so without a separate flag a surprise event would have
to be advertised in order to be allowed to happen. It is a column, a switch
in Phase 13's editor, and three SQL predicates -- never a filter applied
after a read, which works exactly as well until the first caller that forgets.

The public shapes are a projection, and the projection is the security
boundary: nothing is spread, so a column added to event_runs next year does
not ride out through it. The spec, health, cleanup, claims, errors and
member_key are all absent by construction.

The six public event triggers gained `eventUrl` (version 1 -> 2), carrying
?run= because the page lives at the definition's slug while every trigger is
about one occurrence. notify.event-started gained the button, at seedVersion 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
fix(events): staff could not reach their own participation history
Some checks failed
PR Checks / server-tests (pull_request) Failing after 5m57s
PR Checks / client-build (pull_request) Failing after 10m14s
PR Checks / bot-tests (pull_request) Successful in 8m36s
eb167558e3
Found by the live walk, signed in as an admin: /account/events redirected to
the dashboard. `GET /player/events/history` is behind requireAuth alone and
self-scoped on req.user.id -- staff are a superset of players -- but the WEB
has two logged-in shells, and RequirePlayer sends anyone who is not a
`player` out of /account. A single mount there is a screen the reviewing
admin can never open.

Engagement Phase 7 hit this exact wall with the inbox and answered it with
two routes, one pair of components and one mapping. `eventHistoryPath` joins
`inboxPath` and `notificationSettingsPath` in notificationPaths.js rather
than starting a second file with the same comment at the top of it. The
staff path is /admin/events/mine, in the Events section of the sidebar, and
it is the one row in that group with no `roles`.

Also: the eventAnnounce fixture carried no slug, state or `listed`, so
`eventUrl` answered undefined in every test in that file and the new code
was exercised by none of them. The fixture now looks like a definition row,
and three tests cover the link, the unlisted case and the draft case.

The run.failed assertion that came with them was reading the wrong layer:
`baseFor` assembles eventUrl for every trigger and the SEAM drops the keys a
trigger does not declare, so the declaration test is what proves it. Removed,
with a note saying where the rule actually lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
whitlocktech merged commit 2e9ed50e21 into edge 2026-09-08 17:04:55 +00:00
whitlocktech deleted branch feature/events-p14a-public-surface 2026-09-08 17:04:58 +00:00
Sign in to join this conversation.
No description provided.