Files
website/server/src/model/events/eventPublic.model.js
wtclaude ef93c73d2c
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / client-build (pull_request) Successful in 7m50s
PR Checks / server-tests (pull_request) Successful in 13m50s
fix(events): the public phase label finds the phase by key (runicnpc D314)
eventPublic.phaseLabel matched a run's current_phase against p.id, but spec.js
validates and the engine writes the phase's `key`, so every live run's public
page read "Under way" since Events Phase 14a, UO's events included. It now
matches `key` first and still accepts `id` second. The test fixture moves to
`key`, as real specs are, and a unit test covers a UO-shaped spec, the id
fallback, key-before-id and an unknown phase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-06 16:57:46 -05:00

464 lines
19 KiB
JavaScript

// ── 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')
const progress = require('../../events/progress')
// The public calendar's window when a caller names neither end: a few days BACK
// 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.
//
// **The backward tail is not padding — it is the "recent" in §I's "upcoming, live
// and recent".** The default used to start at `now`, so an event that finished an
// hour ago was already gone and a visitor had nowhere to find the results of the
// thing they had just attended. The LIVE half is answered by `listInWindow`'s
// overlap test rather than by this number, so the tail only has to be long enough
// to be a "recently" a reader would recognise.
const DEFAULT_WINDOW_DAYS = 31
const DEFAULT_RECENT_DAYS = 7
// 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 key is a slug an author typed and the label is what they meant it to
* read as, so a page rendering the key 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.
*
* **A run's `current_phase` is the phase's `key`**, which is what `spec.js`
* validates and the engine writes. This matched `id` until RunicNPC stage 9
* (D314), so every live run read "Under way" since Events Phase 14a; `id` is
* still accepted second, for a spec stored in that older shape.
*/
function phaseLabel(spec, phaseKey) {
if (!phaseKey || !spec || !Array.isArray(spec.phases)) return null
const phase =
spec.phases.find((p) => p && p.key === phaseKey) || spec.phases.find((p) => p && p.id === phaseKey)
return (phase && (phase.label || phase.key || phase.id)) || null
}
/**
* One calendar entry, from a materialised run.
*
* **`runId` is published because the event page already publishes it** on every
* occurrence, and `?run=` takes it. The calendar was the one public shape that
* named a run without saying which, so a client holding a run id from elsewhere
* (a module's map marker) had no way to find its event but to fetch every event
* page. A projection has none: nothing is committed to it.
*/
const publicRunEntry = (run) => ({
kind: 'run',
runId: run.id,
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() } = {}) {
// The default `to` is measured from NOW, not from `start` — otherwise the
// backward tail would silently push the horizon a week further out and a caller
// naming only `from` would get a different span than one naming neither.
const start = from
? new Date(from)
: new Date(new Date(now).getTime() - DEFAULT_RECENT_DAYS * recurrence.DAY_MS)
const end = to
? new Date(to)
: new Date(new Date(now).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 {
// **Forecast from `now`, never from `start`.** The default window now
// reaches a week backwards so that "recent" has somewhere to live, and a
// projection into that tail would advertise an occurrence that did not
// happen — a run that WAS created is a real row and arrives above, and one
// that was not is a slot the runner has already passed. A forecast is about
// the future; the tail is about the past. Only the materialised half fills
// it.
const forecastFrom = start > now ? start : new Date(now)
if (forecastFrom < end) {
occurrences = recurrence.occurrencesBetween(
schedule,
definition.timezone || 'UTC',
forecastFrom,
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, userId = 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
// How the live occurrence stands, in the words its steps' modules chose
// (MODULE_API 1.12.0). Only for a live run: a finished one has results, and a
// scheduled one has nothing to count yet. `progress.forRun` never rejects and
// answers `[]` when nobody has anything to say.
const current = live[0] ? publicOccurrence(live[0], spec) : null
if (current) current.progress = await progress.forRun(live[0], { viewer: { userId } })
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,
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,
DEFAULT_RECENT_DAYS,
PAST_RUNS,
}