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

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
This commit is contained in:
2026-09-08 06:18:38 -05:00
parent 6e6c24065c
commit 1667e636bd
39 changed files with 3964 additions and 45 deletions

View File

@@ -283,21 +283,40 @@ test('six are ceilinged authenticated; run.failed is admin on both halves', () =
}
})
test('no public event trigger declares a url — there is no page for one to point at yet', () => {
// `news.post` shipped an example naming `/news/<slug>`, a path that does not
// exist, and the template editor previewed a link that was dead in every mail
// it sent. Phase 14 adds the variable alongside the page.
test('every public event trigger points at the page Phase 14a built, and run.failed at the console', () => {
// The inverse of what this asserted from Phase 10 until Phase 14a, and the
// inversion is the point: `news.post` shipped an example naming `/news/<slug>`,
// a path that did not exist, so the template editor previewed a link that was
// dead in every mail it sent. The variable was withheld until there was a page,
// and it arrived with it.
for (const t of eventTriggers()) {
const urls = t.variables.filter((v) => v.type === 'url')
if (t.id === 'event.run.failed') {
// No `eventUrl` here, deliberately: an admin reading that the machinery
// broke wants the steps and the errors, not the storyline.
assert.deepEqual(urls.map((v) => v.name), ['runUrl'])
assert.match(urls[0].example, /^\/admin\/events\/runs\//)
} else {
assert.deepEqual(urls, [], `${t.id} must declare no url until Phase 14`)
assert.deepEqual(urls.map((v) => v.name), ['eventUrl'], `${t.id}`)
// The example has to carry `?run=`, because that is what makes a link in a
// mail about last Friday open last Friday rather than next Friday.
assert.match(urls[0].example, /^\/site\/events\/[^?]+\?run=/, `${t.id}`)
// Optional, so `email.button` drops itself rather than rendering an inert
// grey label when the event is not public and there is no page.
assert.equal(urls[0].required, false, `${t.id}`)
}
}
})
test('the six public triggers are at version 2 — the url variable is a declaration change', () => {
// A variable added to a declaration is a version bump, not a correction: a rule
// written against version 1 was written against a payload with no link in it.
// `run.failed` gained nothing and stays where it was.
for (const t of eventTriggers()) {
assert.equal(t.version, t.id === 'event.run.failed' ? 1 : 2, `${t.id}`)
}
})
test('every declared variable carries an example, which is what the editor previews with', () => {
for (const t of eventTriggers()) {
for (const v of t.variables) {

View File

@@ -0,0 +1,417 @@
// ── The public event surface (EVENTS_PLAN.md Phase 14a) ────────────────────
//
// The phase's shipped claim: **a visitor with no account sees the calendar, one
// event's page and an arc, and sees nothing an operator did not announce.**
//
// What is worth testing here is almost entirely the second half. The reads
// themselves are joins; the decisions are about what is absent:
//
// • a rehearsal, an unlisted definition and a draft are absent from every
// surface, and absent the same way — a 404 that cannot be told from a slug
// that never existed
// • the plan behind an event (phases, steps, actions, params) is never
// published; a live run carries the LABEL of its phase and nothing else
// • `failed` and `missed` are published as `cancelled`, because the difference
// between them is about the deployment rather than about the event
// • `member_key` never leaves the server, even on a results table
// • a participant's own history obeys the same two exclusions as the calendar,
// so attending an unannounced event does not disclose that it exists
//
// Stubbed at the `.db` layer, the shape `eventSchedule.test.js` uses.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const publicModel = require('../src/model/events/eventPublic.model')
const definitionsDb = require('../src/model/events/eventDefinitions.db')
const runsDb = require('../src/model/events/eventRuns.db')
const seriesDb = require('../src/model/events/eventSeries.db')
const versionsDb = require('../src/model/events/eventVersions.db')
const participantsDb = require('../src/model/events/eventRunParticipants.db')
const db = require('../src/utils/db')
after(() => db.close())
const NOW = new Date('2026-09-01T12:00:00Z')
const SPEC = {
schedule: { kind: 'manual' },
phases: [
{ id: 'muster', label: 'The muster', steps: [{ id: 's1', action: 'core.announce' }] },
{ id: 'assault', label: 'The assault', steps: [] },
],
}
const originals = {}
for (const [name, mod] of [
['definitionsDb', definitionsDb],
['runsDb', runsDb],
['seriesDb', seriesDb],
['versionsDb', versionsDb],
['participantsDb', participantsDb],
]) {
originals[name] = { mod, fns: { ...mod } }
}
const restoreOriginals = () => {
for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
}
let store
const definition = (over = {}) => ({
id: 1,
title: 'The Yew Invasion',
slug: 'the-yew-invasion',
summary: 'Orcish warbands are massing north of Yew.',
body: '<p>They came at dusk.</p>',
image_url: null,
state: 'ready',
listed: true,
timezone: 'America/New_York',
series_id: null,
series_name: null,
series_slug: null,
current_version_id: 100,
spec: SPEC,
...over,
})
const run = (over = {}) => ({
id: 3692,
definition_id: 1,
version_id: 100,
scope: '',
status: 'completed',
// Every one of these is a field the public shapes must NOT carry. They are on
// the fixture on purpose: a `{ ...run }` anywhere in the model would publish
// them, and the assertions below are what would catch it.
health: 'degraded',
cleanup_status: 'incomplete',
claimed_by: 'worker-3',
claim_expires_at: new Date(),
last_error: 'sidecar responded 503',
current_phase: 'assault',
scheduled_for: new Date('2026-08-29T00:00:00Z'),
started_at: new Date('2026-08-29T00:00:05Z'),
ended_at: new Date('2026-08-29T01:30:00Z'),
timezone: 'America/New_York',
rehearsal: false,
results_published_at: new Date('2026-08-29T02:00:00Z'),
...over,
})
function installStubs() {
definitionsDb.getPublicBySlug = async (slug) => {
const d = store.definitions.find((x) => x.slug === slug)
return d && d.state === 'ready' && d.listed ? d : undefined
}
definitionsDb.listPublicBySeries = async (seriesId) =>
store.definitions.filter((d) => d.series_id === seriesId && d.state === 'ready' && d.listed)
definitionsDb.findSchedulable = async ({ listedOnly = false } = {}) =>
store.definitions
.filter((d) => d.state === 'ready' && (!listedOnly || d.listed))
.map((d) => ({ ...d, version_spec: d.spec }))
runsDb.listInWindow = async ({ from, to, publicOnly = false }) =>
store.runs.filter((r) => {
const at = new Date(r.scheduled_for)
if (at < from || at >= to) return false
if (!publicOnly) return true
const d = store.definitions.find((x) => x.id === r.definition_id)
return !r.rehearsal && d && d.listed && d.state !== 'archived'
})
runsDb.listPublicForDefinition = async (id) =>
store.runs
.filter((r) => r.definition_id === id && !r.rehearsal)
.sort((a, b) => new Date(b.scheduled_for) - new Date(a.scheduled_for))
seriesDb.getById = async (id) => store.series.find((s) => s.id === id) || null
seriesDb.getBySlug = async (slug) => store.series.find((s) => s.slug === slug) || null
versionsDb.getById = async (id) => (id === 100 ? { id, spec: SPEC } : null)
participantsDb.listForRun = async () => store.participants
participantsDb.listForUser = async () => store.history
}
beforeEach(() => {
const d = definition()
store = {
definitions: [d],
// The joined shape `listInWindow` answers with.
runs: [{ ...run(), definition_title: d.title, definition_slug: d.slug }],
series: [],
participants: [],
history: [],
}
installStubs()
})
afterEach(restoreOriginals)
// ── The calendar ───────────────────────────────────────────────────────────
test('a calendar entry carries no operational field at all', async () => {
const result = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
assert.equal(result.ok, true)
const [entry] = result.entries
assert.equal(entry.title, 'The Yew Invasion')
// The whole security property of this file, asserted positively: the entry has
// exactly these keys and gaining one is a deliberate act.
assert.deepEqual(Object.keys(entry).sort(), [
'kind', 'live', 'scheduledFor', 'seriesName', 'seriesSlug', 'slug', 'status', 'timezone', 'title',
])
})
test('the calendar defaults to a month from now when no window is given', async () => {
const result = await publicModel.calendar({ now: NOW })
assert.equal(result.ok, true)
assert.equal(new Date(result.window.from).getTime(), NOW.getTime())
const days = (new Date(result.window.to) - new Date(result.window.from)) / 86_400_000
assert.equal(days, publicModel.DEFAULT_WINDOW_DAYS)
})
test('a window wider than the cap is refused rather than served slowly', async () => {
const result = await publicModel.calendar({ from: '2026-01-01', to: '2026-12-31', now: NOW })
assert.equal(result.ok, false)
assert.equal(result.status, 400)
})
test('a projection is not emitted for an instant a run already occupies', async () => {
// The definition recurs weekly on the Saturday its one run already sits on.
store.definitions[0].spec = {
...SPEC,
schedule: { kind: 'weekly', days: ['saturday'], time: '00:00' },
}
const result = await publicModel.calendar({ from: '2026-08-28', to: '2026-08-31', now: NOW })
const at = result.entries.filter(
(e) => new Date(e.scheduledFor).getTime() === new Date('2026-08-29T00:00:00Z').getTime(),
)
assert.equal(at.length, 1)
assert.equal(at[0].kind, 'run')
})
// ── What the public never sees ─────────────────────────────────────────────
test('an unlisted event is absent from the calendar and 404s on its own page', async () => {
store.definitions[0].listed = false
const cal = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
assert.deepEqual(cal.entries, [])
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.ok, false)
assert.equal(page.status, 404)
})
test('a draft answers exactly as an unlisted one does — indistinguishable from no such slug', async () => {
store.definitions[0].state = 'draft'
const draft = await publicModel.event('the-yew-invasion')
const missing = await publicModel.event('no-such-event')
assert.deepEqual(draft, missing)
})
test('a rehearsal is absent from the calendar and from an event page', async () => {
store.runs[0].rehearsal = true
const cal = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
assert.deepEqual(cal.entries, [])
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.ok, true)
assert.deepEqual(page.event.past, [])
})
test('an occurrence publishes no health, no cleanup state, no claim and no error', async () => {
store.runs[0].status = 'running'
const page = await publicModel.event('the-yew-invasion')
const occurrence = page.event.current
for (const leaked of ['health', 'cleanupStatus', 'claimedBy', 'claimExpiresAt', 'lastError', 'versionId']) {
assert.equal(occurrence[leaked], undefined, `${leaked} must not be published`)
}
assert.equal(JSON.stringify(page).includes('sidecar responded 503'), false)
})
test('a live run carries its phase LABEL, and never the spec behind it', async () => {
store.runs[0].status = 'running'
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.event.current.phase, 'The assault')
// The step ids in the fixture spec are the tell: if the spec were published
// anywhere in this answer, this would find it.
assert.equal(JSON.stringify(page).includes('core.announce'), false)
})
test('a phase the pinned version does not name renders nothing rather than an id', async () => {
store.runs[0].status = 'running'
store.runs[0].current_phase = 'a-phase-since-renamed'
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.event.current.phase, null)
})
test('failed and missed are both published as cancelled', async () => {
for (const status of ['failed', 'missed']) {
store.runs[0].status = status
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.event.past[0].status, 'cancelled', status)
}
})
test('paused is published as live — an operator holding a run is not a public state', async () => {
store.runs[0].status = 'paused'
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.event.current.status, 'live')
assert.equal(page.event.live, true)
})
// ── Which side of now an occurrence falls on ───────────────────────────────
//
// Both of these were found by the browser walk, and both are the same mistake:
// the split reading a STATUS where it should read a clock. Dates here are
// relative to the real clock, because `event()` asks `Date.now()` — a run
// "next Friday" has to still be next Friday when this runs.
const inDays = (n) => new Date(Date.now() + n * 86_400_000)
test('a cancelled occurrence in the FUTURE is what is coming, not what happened', async () => {
// It was announced and it has been called off, and "next Friday is off" is
// exactly what somebody checking the calendar came to find out. Filing it
// under "previously" tells them it already happened, which is the one thing
// certainly untrue about it.
store.runs = [
{ ...run({ id: 1, status: 'cancelled', scheduled_for: inDays(4), ended_at: null, results_published_at: null }) },
{ ...run({ id: 2, status: 'scheduled', scheduled_for: inDays(11), ended_at: null, results_published_at: null }) },
]
const page = await publicModel.event('the-yew-invasion')
assert.deepEqual(page.event.upcoming.map((o) => o.runId), [1, 2])
assert.deepEqual(page.event.past, [])
})
test('`next` skips a cancelled occurrence even though it is listed as coming', async () => {
// The headline answers "when is the next one", and a cancelled occurrence is
// not one. An event whose only future occurrence was called off has no `next`
// and says so, while the cancellation is still listed below.
store.runs = [
{ ...run({ id: 1, status: 'cancelled', scheduled_for: inDays(4), ended_at: null, results_published_at: null }) },
]
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.event.next, null)
assert.equal(page.event.upcoming.length, 1)
})
test('a scheduled occurrence whose moment has gone by is in the past', async () => {
// The other direction of the same rule: the runner had not reached it, so its
// status still says `scheduled` while the evening it named is over.
store.runs = [
{ ...run({ id: 1, status: 'scheduled', scheduled_for: inDays(-3), ended_at: null, results_published_at: null }) },
]
const page = await publicModel.event('the-yew-invasion')
assert.deepEqual(page.event.upcoming, [])
assert.deepEqual(page.event.past.map((o) => o.runId), [1])
})
// ── Results ────────────────────────────────────────────────────────────────
test('a results row publishes the score and the rank and never the member key', async () => {
store.participants = [
{ id: 1, member_key: 'serial:0x40001234', user_id: 7, score: 1420, rank_at: 1, meta: { name: 'Aldric' } },
]
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.event.results.participants[0].name, 'Aldric')
assert.equal(page.event.results.participants[0].rank, 1)
assert.equal(JSON.stringify(page).includes('0x40001234'), false)
assert.equal(JSON.stringify(page).includes('user_id'), false)
})
test('an unpublished results table is absent rather than empty', async () => {
store.runs[0].results_published_at = null
store.participants = [{ id: 1, member_key: 'k', score: 10, rank_at: null, meta: null }]
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.event.results, null)
})
test('?run= selects which occurrence the results are about', async () => {
const older = {
...run({ id: 3600, scheduled_for: new Date('2026-08-22T00:00:00Z') }),
definition_title: 'The Yew Invasion',
definition_slug: 'the-yew-invasion',
}
store.runs.push(older)
const page = await publicModel.event('the-yew-invasion', { runId: '3600' })
assert.equal(page.event.results.runId, 3600)
})
test('a run id belonging to no occurrence of this event renders the page anyway', async () => {
// A stale link in a months-old mail should land on the event it was about, not
// on a dead end.
const page = await publicModel.event('the-yew-invasion', { runId: '999999' })
assert.equal(page.ok, true)
assert.equal(page.event.slug, 'the-yew-invasion')
})
// ── The arc ────────────────────────────────────────────────────────────────
test('a series with nothing listed in it is a 404, not an empty page', async () => {
store.series = [{ id: 5, name: 'The Yew Campaign', slug: 'the-yew-campaign', description: null }]
store.definitions[0].series_id = 5
store.definitions[0].listed = false
const arc = await publicModel.series('the-yew-campaign')
assert.equal(arc.ok, false)
assert.equal(arc.status, 404)
})
test('an arc lists its listed events and nothing about their plans', async () => {
store.series = [{ id: 5, name: 'The Yew Campaign', slug: 'the-yew-campaign', description: 'An arc.' }]
store.definitions[0].series_id = 5
const arc = await publicModel.series('the-yew-campaign')
assert.equal(arc.ok, true)
assert.deepEqual(Object.keys(arc.series.events[0]).sort(), ['imageUrl', 'slug', 'summary', 'title'])
})
// ── Participation history ──────────────────────────────────────────────────
test('history publishes the rank as null until results were published', async () => {
store.history = [
{
id: 9,
run_id: 3692,
score: 1420,
rank_at: null,
joined_at: new Date(),
meta: null,
scheduled_for: new Date('2026-08-29T00:00:00Z'),
started_at: null,
ended_at: null,
status: 'completed',
scope: '',
timezone: 'UTC',
results_published_at: null,
definition_title: 'The Yew Invasion',
definition_slug: 'the-yew-invasion',
series_name: null,
series_slug: null,
},
]
const result = await publicModel.history(7)
assert.equal(result.entries[0].rank, null)
assert.equal(result.entries[0].resultsPublishedAt, null)
assert.equal(result.entries[0].score, 1420)
})
test('history publishes no member key', async () => {
store.history = [
{
id: 9,
run_id: 3692,
member_key: 'serial:0x40001234',
score: 1,
rank_at: 1,
joined_at: new Date(),
meta: null,
scheduled_for: new Date(),
status: 'completed',
timezone: 'UTC',
definition_title: 't',
definition_slug: 's',
},
]
const result = await publicModel.history(7)
assert.equal(JSON.stringify(result).includes('0x40001234'), false)
})