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

@@ -0,0 +1,409 @@
// ── The public event surface ───────────────────────────────────────────────
//
// EVENTS.md § API surface, and Phase 14a of EVENTS_PLAN.md: the calendar an
// anonymous visitor reads, one event's page, and an arc.
//
// **This file is a projection, and the projection is the security boundary.**
// Every other reader of these tables is staff, and every field they are shown is
// one somebody with a role was allowed to see. What comes out of here is read by
// nobody at all, so the rule is the opposite of the admin shapes': nothing is
// spread, and a field reaches a public entry because a line below put it there.
// The day somebody adds a column to `event_runs` — a claim token, an operator's
// note, a last error — a `{ ...run }` anywhere here would publish it, silently,
// in the release after the one anybody reviewed.
//
// Three things are absent from every shape below, and each is a decision:
//
// • **The spec.** Phases, steps, actions and their params are the plan for
// changing a live world. A visitor is told what is happening and when, and
// the LABEL of the phase while it is happening; the steps are the operator's.
// • **Health, cleanup, claims and errors.** A degraded run is a fact about the
// deployment's plumbing. "The event is running" is the fact about the event.
// • **`member_key`.** It is the game's own identifier for a character, it is
// module-opaque, and core cannot say what it discloses — so it stays unsent
// even on a results table where every other column is published.
//
// **What makes something public is `listed` AND `ready` AND not a rehearsal**,
// and all three live in SQL (`eventDefinitions.db.getPublicBySlug`, and
// `publicOnly` on `eventRuns.db.listInWindow`). Filtering in JavaScript after
// the read would work exactly as well, right up until the first caller that
// forgot to.
const definitionsDb = require('./eventDefinitions.db')
const runsDb = require('./eventRuns.db')
const seriesDb = require('./eventSeries.db')
const versionsDb = require('./eventVersions.db')
const participantsDb = require('./eventRunParticipants.db')
const calendarModel = require('./eventCalendar.model')
const recurrence = require('../../events/recurrence')
// The public calendar's window when a caller names neither end: now through a
// month out. A visitor arriving at /site/events wants "what is on", and a client
// that had to compute a window before it could ask anything would make every
// deep link carry two ISO instants.
const DEFAULT_WINDOW_DAYS = 31
// How many past occurrences an event page carries. It shows what is next and
// what happened recently; the whole history of a three-year-old weekly event is
// a different screen and nobody has asked for one.
const PAST_RUNS = 10
const RESULTS_LIMIT = 100
// The status words a visitor is told. `paused` maps to `live` deliberately: an
// operator holding a run for two minutes while they deal with something is not a
// state a public page should render, and a page that said "paused" would invite
// a question whose answer is internal.
const PUBLIC_STATUS = {
scheduled: 'scheduled',
starting: 'live',
running: 'live',
paused: 'live',
ending: 'live',
completed: 'completed',
cancelled: 'cancelled',
failed: 'cancelled',
missed: 'cancelled',
}
/**
* The public status word for a run.
*
* **`failed` and `missed` are published as `cancelled`**, which is the mapping
* worth defending. To a visitor the three are one event: it was on the calendar
* and it did not happen. The difference between them is entirely about the
* deployment — `failed` names broken machinery, `missed` names a process that
* was down when the schedule came round — so publishing either word would tell a
* stranger something true about the server and nothing about the event.
*/
const publicStatus = (status) => PUBLIC_STATUS[status] || 'scheduled'
/** Is this a run a visitor should be shown as happening now? */
const isLive = (status) => publicStatus(status) === 'live'
/**
* The label of the phase a run is in, resolved from the PINNED version's spec.
*
* A phase id is a slug an author typed and the label is what they meant it to
* read as, so a page rendering the id would show `phase-2` to the public. A
* phase the spec does not name answers null and the page shows nothing, which is
* the right answer for a version edited since: the run pinned the old spec and
* the old spec is what it is executing.
*/
function phaseLabel(spec, phaseId) {
if (!phaseId || !spec || !Array.isArray(spec.phases)) return null
const phase = spec.phases.find((p) => p && p.id === phaseId)
return (phase && (phase.label || phase.id)) || null
}
/** One calendar entry, from a materialised run. */
const publicRunEntry = (run) => ({
kind: 'run',
title: run.definition_title,
slug: run.definition_slug,
seriesName: run.series_name || null,
seriesSlug: run.series_slug || null,
scheduledFor: run.scheduled_for,
timezone: run.timezone,
status: publicStatus(run.status),
live: isLive(run.status),
})
/**
* One calendar entry, from a projection.
*
* A projection is arithmetic past the materialisation horizon (§I), and the
* public entry keeps the distinction for the visitor's version of the operator's
* reason: a forecast three weeks out is a plan rather than a booking, and a page
* drawing the two identically would promise something nothing has committed to.
* `adjusted` rides along because a DST-shifted occurrence is worth explaining
* before it happens rather than after.
*/
const publicProjectedEntry = (definition, occurrence) => ({
kind: 'projected',
title: definition.title,
slug: definition.slug,
seriesName: definition.series_name || null,
seriesSlug: definition.series_slug || null,
scheduledFor: occurrence.at,
timezone: definition.timezone,
status: 'scheduled',
live: false,
adjusted: occurrence.adjusted,
shiftMinutes: occurrence.shiftMinutes,
})
/**
* The public calendar for a window.
*
* **The run half is read here rather than borrowed from `eventCalendar.model`**,
* and the reason is the file header's: that model answers with `status`,
* `health`, `version` and `waitingSteps` on every entry, so reusing it would
* mean building the public answer by DELETING fields from an operator's, which
* is the direction that fails silently. The arithmetic IS shared —
* `occurrencesBetween` is the same function the runner calls, so a forecast
* still cannot disagree with what later appears — and so are the window bound
* and the entry cap, which are a defence against an expensive query on the one
* surface that has no login in front of it.
*/
async function calendar({ from, to, seriesId = null, now = new Date() } = {}) {
const start = from ? new Date(from) : new Date(now)
const end = to ? new Date(to) : new Date(start.getTime() + DEFAULT_WINDOW_DAYS * recurrence.DAY_MS)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return { ok: false, status: 400, errors: ['from and to must be dates'] }
}
if (end <= start) {
return { ok: false, status: 400, errors: ['to must be after from'] }
}
if (end - start > calendarModel.MAX_WINDOW_DAYS * recurrence.DAY_MS) {
return {
ok: false,
status: 400,
errors: [`the window may span at most ${calendarModel.MAX_WINDOW_DAYS} days`],
}
}
const runs = await runsDb.listInWindow({ from: start, to: end, seriesId, publicOnly: true })
const entries = runs.map(publicRunEntry)
// Every instant a run already occupies, so the fortnight inside the horizon is
// not drawn twice — and so a CANCELLED occurrence is not re-forecast as though
// it were still coming. The same key the admin calendar uses, for the same
// reason: projections are per definition at the empty scope.
const taken = new Set(
runs
.filter((r) => !r.scope)
.map((r) => `${r.definition_id}@${new Date(r.scheduled_for).getTime()}`),
)
const definitions = await definitionsDb.findSchedulable({ listedOnly: true })
for (const definition of definitions) {
if (seriesId && Number(definition.series_id) !== Number(seriesId)) continue
const schedule = definition.version_spec?.schedule
if (!schedule || schedule.kind === 'manual') continue
let occurrences = []
try {
occurrences = recurrence.occurrencesBetween(schedule, definition.timezone || 'UTC', start, end)
} catch {
// A version whose schedule the recurrence engine will not read is one the
// runner will not expand either. The calendar then shows that definition's
// materialised runs and no forecast, rather than failing the whole page.
continue
}
for (const occurrence of occurrences) {
if (taken.has(`${definition.id}@${occurrence.at.getTime()}`)) continue
entries.push(publicProjectedEntry(definition, occurrence))
}
}
entries.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor))
return {
ok: true,
status: 200,
window: { from: start, to: end },
entries: entries.slice(0, calendarModel.MAX_ENTRIES),
truncated: entries.length > calendarModel.MAX_ENTRIES,
}
}
/** One participant, as a results table publishes them. */
const publicParticipant = (p) => ({
// NOT `memberKey` — see the file header. A display name is whatever the module
// chose to put in `meta`, because core has no name for a character and must
// not invent one from the key.
name: (p.meta && (p.meta.name || p.meta.displayName)) || null,
score: p.score,
rank: p.rank_at,
meta: p.meta || null,
})
/** One occurrence, as an event page lists it. */
const publicOccurrence = (run, spec) => ({
runId: run.id,
scheduledFor: run.scheduled_for,
timezone: run.timezone,
startedAt: run.started_at,
endedAt: run.ended_at,
status: publicStatus(run.status),
live: isLive(run.status),
scope: run.scope || null,
phase: isLive(run.status) ? phaseLabel(spec, run.current_phase) : null,
resultsPublishedAt: run.results_published_at || null,
})
/**
* One event's public page: the storyline, its arc, its occurrences, and a
* results table when there is one to show.
*
* **`runId` selects WHICH occurrence the results are about, and it is optional
* for a reason that exists only because of the announcements.** The page lives
* at the definition's slug, so a weekly event has one address and a visitor
* arriving at it should be shown what is next. But an `event.run.completed` mail
* is about ONE occurrence, and a link in it that opened next Friday's would
* answer a different question from the one the reader clicked. So the trigger's
* `eventUrl` carries `?run=`, and this is what resolves it.
*
* **A `runId` that does not belong to this definition is ignored rather than
* refused.** It names some other event's run, or none; the honest answer to
* "show me this event" is still this event, and a 404 for the whole page would
* turn a stale link in a months-old mail into a dead end rather than a page
* about the thing the mail was about.
*/
async function event(slug, { runId = null } = {}) {
const definition = await definitionsDb.getPublicBySlug(String(slug || ''))
if (!definition) return { ok: false, status: 404, errors: ['Not found'] }
const series = definition.series_id ? await seriesDb.getById(definition.series_id) : null
const runs = await runsDb.listPublicForDefinition(definition.id, PAST_RUNS + 20)
const now = Date.now()
const live = runs.filter((r) => isLive(r.status))
// **Split on the instant, not on the status**, and the difference is visible
// in both directions. A `missed` run is in the past whatever its status says,
// and so is a `scheduled` one whose moment went by while the runner had not
// reached it — but a run an operator CANCELLED next Friday is still next
// Friday, and filing it under "previously" tells a visitor it already
// happened, which is the one thing that is certainly untrue about it. That a
// cancelled occurrence still appears under what is coming is the point:
// "next Friday is off" is exactly what somebody checking the calendar came to
// find out.
const upcoming = runs
.filter((r) => !live.includes(r) && new Date(r.scheduled_for).getTime() >= now)
.sort((a, b) => new Date(a.scheduled_for) - new Date(b.scheduled_for))
const past = runs.filter((r) => !live.includes(r) && !upcoming.includes(r)).slice(0, PAST_RUNS)
// `next` is narrower than `upcoming[0]`, deliberately: the headline answers
// "when is the next one", and a cancelled occurrence is not one. An event
// whose only future occurrence has been called off has no `next` and says so,
// while the cancellation itself is still listed below.
const next = upcoming.find((r) => r.status === 'scheduled') || null
// Which occurrence the results table is about. An explicit `run` wins; then a
// live one, because that is what the visitor is looking at; then the most
// recent that actually published results, because a page with a table on it is
// more use than one with an empty heading.
const named = runId ? runs.find((r) => String(r.id) === String(runId)) : null
const resultsRun = named || live[0] || past.find((r) => r.results_published_at) || null
let participants = []
if (resultsRun && resultsRun.results_published_at) {
participants = (await participantsDb.listForRun(resultsRun.id, RESULTS_LIMIT)).map(
publicParticipant,
)
}
// Phase labels come from the version the run is EXECUTING rather than from the
// definition's working draft, which an author may be halfway through editing.
// One extra read, and only when there is a run to label at all.
const specRun = named || live[0] || null
const spec = specRun ? (await versionsDb.getById(specRun.version_id))?.spec || null : null
return {
ok: true,
status: 200,
event: {
title: definition.title,
slug: definition.slug,
summary: definition.summary,
body: definition.body,
imageUrl: definition.image_url,
timezone: definition.timezone,
series: series ? { name: series.name, slug: series.slug } : null,
live: live.length > 0,
current: live[0] ? publicOccurrence(live[0], spec) : null,
next: next ? publicOccurrence(next, spec) : null,
upcoming: upcoming.map((r) => publicOccurrence(r, spec)),
past: past.map((r) => publicOccurrence(r, spec)),
results:
resultsRun && resultsRun.results_published_at
? {
runId: resultsRun.id,
scheduledFor: resultsRun.scheduled_for,
publishedAt: resultsRun.results_published_at,
participants,
}
: null,
},
}
}
/**
* One arc: the series, and the listed events in it in the order an editor
* dragged them into.
*
* **A series with no listed events is a 404 rather than an empty page.** The arc
* is a label on its definitions and nothing else, so a page for an empty one
* would publish the single fact that an operator has named something they have
* not announced.
*/
async function series(slug) {
const row = await seriesDb.getBySlug(String(slug || ''))
if (!row) return { ok: false, status: 404, errors: ['Not found'] }
const definitions = await definitionsDb.listPublicBySeries(row.id)
if (!definitions.length) return { ok: false, status: 404, errors: ['Not found'] }
return {
ok: true,
status: 200,
series: {
name: row.name,
slug: row.slug,
description: row.description,
events: definitions.map((d) => ({
title: d.title,
slug: d.slug,
summary: d.summary,
imageUrl: d.image_url,
})),
},
}
}
/**
* One account's participation history.
*
* Self-scoped by the caller's own id and nothing else. There is no route on
* which one account reads another's, and deliberately no id parameter that could
* later grow into one.
*/
async function history(userId, { limit = 50, before = null } = {}) {
const rows = await participantsDb.listForUser(userId, { limit, before })
return {
ok: true,
status: 200,
entries: rows.map((r) => ({
id: r.id,
runId: r.run_id,
title: r.definition_title,
slug: r.definition_slug,
seriesName: r.series_name || null,
seriesSlug: r.series_slug || null,
scheduledFor: r.scheduled_for,
startedAt: r.started_at,
endedAt: r.ended_at,
timezone: r.timezone,
status: publicStatus(r.status),
joinedAt: r.joined_at,
score: r.score,
// Null until `core.results.publish` ran. The screen says so rather than
// inventing a position nobody computed.
rank: r.rank_at,
resultsPublishedAt: r.results_published_at || null,
meta: r.meta || null,
})),
}
}
module.exports = {
calendar,
event,
series,
history,
publicStatus,
phaseLabel,
DEFAULT_WINDOW_DAYS,
PAST_RUNS,
}