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
464 lines
19 KiB
JavaScript
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,
|
|
}
|