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:
@@ -11,6 +11,9 @@ const hydrate = (row) =>
|
||||
row && {
|
||||
...row,
|
||||
spec: parseJson(row.spec, null),
|
||||
// TINYINT(1) arrives as 0/1. Every reader of this column asks a yes/no
|
||||
// question, and the public model's filters compare against a boolean.
|
||||
listed: Boolean(row.listed),
|
||||
}
|
||||
|
||||
// `current_version` is joined rather than stored: the list screen shows "v3" and
|
||||
@@ -60,8 +63,8 @@ const insert = async (d) => {
|
||||
const result = await query(
|
||||
`INSERT INTO event_definitions
|
||||
(title, slug, summary, body, image_url, owner_module, series_id, series_order,
|
||||
concurrency_key, grace_seconds, timezone, spec, created_by, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
concurrency_key, grace_seconds, timezone, listed, spec, created_by, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
d.title,
|
||||
d.slug,
|
||||
@@ -74,6 +77,7 @@ const insert = async (d) => {
|
||||
d.concurrency_key,
|
||||
d.grace_seconds,
|
||||
d.timezone,
|
||||
d.listed ? 1 : 0,
|
||||
JSON.stringify(d.spec),
|
||||
d.created_by,
|
||||
d.created_by,
|
||||
@@ -87,7 +91,7 @@ const update = (id, d) =>
|
||||
`UPDATE event_definitions
|
||||
SET title = ?, slug = ?, summary = ?, body = ?, image_url = ?, series_id = ?,
|
||||
series_order = ?, concurrency_key = ?, grace_seconds = ?, timezone = ?,
|
||||
spec = ?, updated_by = ?
|
||||
listed = ?, spec = ?, updated_by = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
d.title,
|
||||
@@ -100,6 +104,7 @@ const update = (id, d) =>
|
||||
d.concurrency_key,
|
||||
d.grace_seconds,
|
||||
d.timezone,
|
||||
d.listed ? 1 : 0,
|
||||
JSON.stringify(d.spec),
|
||||
d.updated_by,
|
||||
id,
|
||||
@@ -140,20 +145,51 @@ const markReady = (id, versionId, userId) =>
|
||||
* its arc exactly as a materialised run is, and a second query to learn the name
|
||||
* of a row this one already reached would be two round trips for a join.
|
||||
*/
|
||||
const findSchedulable = async () => {
|
||||
const findSchedulable = async ({ listedOnly = false } = {}) => {
|
||||
const rows = await query(
|
||||
`SELECT d.id, d.title, d.slug, d.timezone, d.grace_seconds, d.concurrency_key,
|
||||
d.current_version_id, d.series_id, v.spec AS version_spec,
|
||||
`SELECT d.id, d.title, d.slug, d.summary, d.image_url, d.timezone, d.grace_seconds,
|
||||
d.concurrency_key, d.current_version_id, d.series_id, v.spec AS version_spec,
|
||||
s.name AS series_name, s.slug AS series_slug
|
||||
FROM event_definitions d
|
||||
JOIN event_versions v ON v.id = d.current_version_id
|
||||
LEFT JOIN event_series s ON s.id = d.series_id
|
||||
WHERE d.state = 'ready'
|
||||
WHERE d.state = 'ready'${listedOnly ? ' AND d.listed = 1' : ''}
|
||||
ORDER BY d.id`,
|
||||
)
|
||||
return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* One definition by slug, for the PUBLIC event page (Phase 14a).
|
||||
*
|
||||
* `ready` and `listed` are both in the WHERE rather than checked by the caller,
|
||||
* so an unlisted event answers exactly as a nonexistent one does — a 404 that
|
||||
* cannot be told from "no such slug". A caller that filtered afterwards would
|
||||
* be one forgotten early-return away from publishing a draft.
|
||||
*
|
||||
* An ARCHIVED definition is deliberately absent too. Archiving is what delete
|
||||
* means on the admin screen, and a page that kept answering afterwards would
|
||||
* make the only delete this feature has do nothing an operator could see.
|
||||
*/
|
||||
const getPublicBySlug = async (slug) => {
|
||||
const [row] = await query(
|
||||
`${SELECT_LIST} WHERE d.slug = ? AND d.state = 'ready' AND d.listed = 1`,
|
||||
[slug],
|
||||
)
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/** Every listed, ready definition in one series, in the arc's own order. */
|
||||
const listPublicBySeries = async (seriesId) => {
|
||||
const rows = await query(
|
||||
`${SELECT_LIST}
|
||||
WHERE d.series_id = ? AND d.state = 'ready' AND d.listed = 1
|
||||
ORDER BY d.series_order, d.id`,
|
||||
[seriesId],
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive. Never a hard delete while runs reference it (§ API surface) — and the
|
||||
* schema would refuse one anyway, because `event_runs.version_id` RESTRICTs.
|
||||
@@ -166,6 +202,8 @@ module.exports = {
|
||||
list,
|
||||
getById,
|
||||
getBySlug,
|
||||
getPublicBySlug,
|
||||
listPublicBySeries,
|
||||
slugTaken,
|
||||
findSchedulable,
|
||||
insert,
|
||||
|
||||
@@ -124,6 +124,15 @@ async function validate(input, { existing = null } = {}) {
|
||||
const seriesOrder = Number(seriesOrderRaw)
|
||||
if (!Number.isInteger(seriesOrder)) errors.push('seriesOrder must be an integer')
|
||||
|
||||
// Whether this event is announced on the public calendar (Phase 14a). It is
|
||||
// NOT whether it may run: `state` answers that, and the two are separate
|
||||
// precisely because publishing is what makes a definition runnable — an
|
||||
// unlisted event still schedules, still runs and is still on the admin
|
||||
// calendar. A missing key means "leave it as it was", and a NEW definition
|
||||
// defaults to listed, which is the column's own default and the ordinary
|
||||
// case; unlisting is the deliberate act.
|
||||
const listed = body.listed === undefined ? (existing ? Boolean(existing.listed) : true) : Boolean(body.listed)
|
||||
|
||||
// ── the spec ──
|
||||
const rawSpec = body.spec === undefined ? existing?.spec ?? spec.emptySpec() : body.spec
|
||||
const known = existing?.spec ? spec.actionIdsIn(existing.spec) : []
|
||||
@@ -159,6 +168,7 @@ async function validate(input, { existing = null } = {}) {
|
||||
concurrency_key: concurrencyKey,
|
||||
grace_seconds: graceSeconds,
|
||||
timezone,
|
||||
listed,
|
||||
spec: checked.spec,
|
||||
},
|
||||
}
|
||||
|
||||
409
server/src/model/events/eventPublic.model.js
Normal file
409
server/src/model/events/eventPublic.model.js
Normal 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,
|
||||
}
|
||||
@@ -75,6 +75,52 @@ async function listForRun(runId, limit = 500) {
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* One account's participation history, most recent event first (Phase 14a).
|
||||
*
|
||||
* **Joined all the way out to the definition, and the join is the access
|
||||
* control.** A rehearsal is excluded by §D's own rule, and an unlisted
|
||||
* definition is excluded because unlisting is what an operator does to an event
|
||||
* they are not announcing — a history that named it would announce it to
|
||||
* everyone who attended, which is everyone who could tell anybody.
|
||||
*
|
||||
* `member_key` is NOT selected. It is the game's identifier for a character and
|
||||
* the caller is a player reading their own page; the run, the date, the score
|
||||
* and the rank are what a history is, and the key adds a module-opaque string
|
||||
* nothing on the page can render.
|
||||
*
|
||||
* `rank_at` is null until results are published, and that is a real state the
|
||||
* screen shows rather than an error — a run whose participants are collected
|
||||
* and unranked is exactly what Phase 10 made visible on the admin side.
|
||||
*/
|
||||
async function listForUser(userId, { limit = 50, before = null } = {}) {
|
||||
const n = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||
const args = [userId]
|
||||
// A keyset cursor on the participation row rather than an offset: the list
|
||||
// gains a row every time the reader attends something, and an offset page two
|
||||
// would skip whatever arrived in between.
|
||||
const cursor = before ? ' AND p.id < ?' : ''
|
||||
if (before) args.push(before)
|
||||
const rows = await query(
|
||||
`SELECT p.id, p.run_id, p.score, p.rank_at, p.joined_at, p.meta,
|
||||
r.scheduled_for, r.started_at, r.ended_at, r.status, r.scope,
|
||||
r.timezone, r.results_published_at,
|
||||
d.title AS definition_title, d.slug AS definition_slug,
|
||||
s.name AS series_name, s.slug AS series_slug
|
||||
FROM event_run_participants p
|
||||
JOIN event_runs r ON r.id = p.run_id
|
||||
JOIN event_definitions d ON d.id = r.definition_id
|
||||
LEFT JOIN event_series s ON s.id = d.series_id
|
||||
WHERE p.user_id = ?${cursor}
|
||||
AND r.rehearsal = 0
|
||||
AND d.listed = 1
|
||||
ORDER BY p.id DESC
|
||||
LIMIT ${n}`,
|
||||
args,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** How many the run has. Its own query because the trigger payload needs only this. */
|
||||
async function countForRun(runId) {
|
||||
const rows = await query('SELECT COUNT(*) AS n FROM event_run_participants WHERE run_id = ?', [runId])
|
||||
@@ -116,4 +162,4 @@ async function rankRun(runId) {
|
||||
return Number(result.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { record, listForRun, countForRun, rankRun }
|
||||
module.exports = { record, listForRun, listForUser, countForRun, rankRun }
|
||||
|
||||
@@ -116,13 +116,30 @@ const materialise = async (run) => {
|
||||
* a run records the zone it was COMPUTED in and a definition's zone can be
|
||||
* edited afterwards.
|
||||
*/
|
||||
const listInWindow = async ({ from, to, status = null, scope = null, seriesId = null, limit = 500 } = {}) => {
|
||||
const listInWindow = async ({
|
||||
from,
|
||||
to,
|
||||
status = null,
|
||||
scope = null,
|
||||
seriesId = null,
|
||||
limit = 500,
|
||||
publicOnly = false,
|
||||
} = {}) => {
|
||||
const where = ['r.scheduled_for >= ?', 'r.scheduled_for < ?']
|
||||
const args = [from, to]
|
||||
if (status) {
|
||||
where.push('r.status = ?')
|
||||
args.push(status)
|
||||
}
|
||||
// The public calendar's two exclusions, in SQL rather than in the model that
|
||||
// maps the rows. A rehearsal "is excluded from the public calendar and from
|
||||
// participation history" by §D's own column comment, and an unlisted
|
||||
// definition is one an operator chose not to announce. Both belong in the
|
||||
// query because a filter applied after the read is a filter somebody can
|
||||
// forget in the next caller.
|
||||
if (publicOnly) {
|
||||
where.push('r.rehearsal = 0', 'd.listed = 1', "d.state <> 'archived'")
|
||||
}
|
||||
if (scope !== null && scope !== undefined) {
|
||||
where.push('r.scope = ?')
|
||||
args.push(scope)
|
||||
@@ -497,6 +514,33 @@ const reclaimStale = async (now) => {
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* One definition's public occurrences, newest first (Phase 14a).
|
||||
*
|
||||
* Rehearsals are excluded here rather than by the caller, for `listInWindow`'s
|
||||
* reason. The definition's own `listed`/`state` are NOT re-checked: the only
|
||||
* caller has already resolved the definition through `getPublicBySlug`, and a
|
||||
* second copy of that rule is a second thing to keep in step with the first.
|
||||
*
|
||||
* `scheduled` runs come back too — an upcoming occurrence is exactly what a
|
||||
* visitor came to the page for — and the caller splits past from future on the
|
||||
* instant rather than on the status, because a `missed` run is in the past
|
||||
* whatever its status says.
|
||||
*/
|
||||
const listPublicForDefinition = async (definitionId, limit = 50) => {
|
||||
const n = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||
const rows = await query(
|
||||
`SELECT r.*, v.version AS version_number
|
||||
FROM event_runs r
|
||||
JOIN event_versions v ON v.id = r.version_id
|
||||
WHERE r.definition_id = ? AND r.rehearsal = 0
|
||||
ORDER BY r.scheduled_for DESC, r.id DESC
|
||||
LIMIT ${n}`,
|
||||
[definitionId],
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** Terminal runs that ended before `before` — what the log retention sweep walks. */
|
||||
const terminalBefore = async (before, limit = 500) => {
|
||||
const n = Math.min(Math.max(Number(limit) || 500, 1), 5000)
|
||||
@@ -516,6 +560,7 @@ module.exports = {
|
||||
getById,
|
||||
materialise,
|
||||
listInWindow,
|
||||
listPublicForDefinition,
|
||||
repinScheduled,
|
||||
listScheduledFor,
|
||||
findOccurrence,
|
||||
|
||||
@@ -29,6 +29,11 @@ const getById = async (id) => {
|
||||
return row || null
|
||||
}
|
||||
|
||||
const getBySlug = async (slug) => {
|
||||
const [row] = await query(`${SELECT_LIST} WHERE s.slug = ?`, [slug])
|
||||
return row || null
|
||||
}
|
||||
|
||||
const exists = async (id) => {
|
||||
const [row] = await query('SELECT id FROM event_series WHERE id = ?', [id])
|
||||
return Boolean(row)
|
||||
@@ -70,4 +75,4 @@ const update = (id, s) =>
|
||||
*/
|
||||
const remove = (id) => query('DELETE FROM event_series WHERE id = ?', [id])
|
||||
|
||||
module.exports = { list, getById, exists, slugTaken, insert, update, remove }
|
||||
module.exports = { list, getById, getBySlug, exists, slugTaken, insert, update, remove }
|
||||
|
||||
Reference in New Issue
Block a user