// ── 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: 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 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() } = {}) { // 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 } = {}) { 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, DEFAULT_RECENT_DAYS, PAST_RUNS, }