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:
@@ -185,15 +185,22 @@ const TRIGGERS = [
|
||||
// runs of the same definition the ids differ, so a weekly event is not
|
||||
// throttled by last week's.
|
||||
//
|
||||
// **None of the six public ones declares a `url` variable, deliberately.**
|
||||
// There is no public event page until Phase 14 — `App.jsx` mounts nothing
|
||||
// under `/site/events` — and `news.post` has already paid for this mistake
|
||||
// once: its `postUrl` example named `/news/<slug>`, a path that does not
|
||||
// exist, and the template editor previewed a link that was dead in every mail
|
||||
// it sent. A variable added in Phase 14 alongside the page it points at is a
|
||||
// version bump; a variable shipped now is a 404 in an operator's first
|
||||
// announcement. `run.failed` is the exception because its destination exists
|
||||
// today: `/admin/events/runs/:runId` is a real route and an admin can read it.
|
||||
// **All six public ones now declare `eventUrl`, and Phase 14a is what made
|
||||
// that legal.** Until it there was no public event page at all — `App.jsx`
|
||||
// mounted nothing under `/site/events` — and `news.post` had already paid for
|
||||
// that mistake once: its `postUrl` example named `/news/<slug>`, a path that
|
||||
// did not exist, so the template editor previewed a link that was dead in
|
||||
// every mail it sent. The variable arrived with the page it points at, which
|
||||
// is what makes this a version bump (1 -> 2) rather than a correction.
|
||||
//
|
||||
// **It carries `?run=`, and the query string is the whole reason it is a run
|
||||
// url and not an event url.** The page lives at the DEFINITION's slug, so a
|
||||
// weekly event has one stable address — but every one of these triggers is
|
||||
// about one OCCURRENCE, and a mail about last Friday's invasion whose link
|
||||
// opened next Friday's would answer a different question from the one the
|
||||
// reader clicked. `run.failed` keeps its own `runUrl` into the admin console
|
||||
// and gains nothing here: an admin reading about broken machinery wants the
|
||||
// console, not the storyline.
|
||||
{
|
||||
id: 'event.run.scheduled',
|
||||
label: 'Event — scheduled',
|
||||
@@ -202,7 +209,7 @@ const TRIGGERS = [
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
@@ -225,6 +232,12 @@ const TRIGGERS = [
|
||||
{ name: 'startsAtLabel', type: 'string', required: false,
|
||||
example: 'Saturday 12 September at 8:00 pm (America/New_York)',
|
||||
description: 'The start time written out in the shard-local zone, for a mail to read.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -235,7 +248,7 @@ const TRIGGERS = [
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
@@ -258,6 +271,12 @@ const TRIGGERS = [
|
||||
{ name: 'startsAtLabel', type: 'string', required: false,
|
||||
example: 'Saturday 12 September at 8:00 pm (America/New_York)',
|
||||
description: 'The start time written out in the shard-local zone, for a mail to read.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -268,7 +287,7 @@ const TRIGGERS = [
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
@@ -282,6 +301,12 @@ const TRIGGERS = [
|
||||
description: 'Which phase this is, counting from 1.' },
|
||||
{ name: 'phaseCount', type: 'int', required: true, example: 4,
|
||||
description: 'How many phases the pinned version has in total.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -292,12 +317,18 @@ const TRIGGERS = [
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -308,7 +339,7 @@ const TRIGGERS = [
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
@@ -324,6 +355,12 @@ const TRIGGERS = [
|
||||
description: 'How many participants the run recorded. Zero when nothing collected any.' },
|
||||
{ name: 'durationMinutes', type: 'int', required: true, example: 95,
|
||||
description: 'How long the run took, start to end, in whole minutes.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -334,7 +371,7 @@ const TRIGGERS = [
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
@@ -345,6 +382,12 @@ const TRIGGERS = [
|
||||
// string is for the run console and would read as gibberish in a mail.
|
||||
{ name: 'reason', type: 'string', required: false, example: 'The shard is down for an emergency patch.',
|
||||
description: 'What the staff member gave as the reason, when they gave one.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -369,8 +412,9 @@ const TRIGGERS = [
|
||||
description: 'The phase it failed in, when it had entered one.' },
|
||||
{ name: 'error', type: 'string', required: false, example: 'sidecar responded 503',
|
||||
description: 'The run’s last error, verbatim from the run row.' },
|
||||
// The one url variable in this file's Phase 10 block, and the reason is
|
||||
// that this route exists TODAY. See the note above the six.
|
||||
// The admin console, not the public page — and this trigger gains no
|
||||
// `eventUrl` at all. An admin reading that the machinery broke wants the
|
||||
// steps and the errors, not the storyline. See the note above the six.
|
||||
{ name: 'runUrl', type: 'url', required: true, example: '/admin/events/runs/3692',
|
||||
description: 'Site-relative path to the run console.' },
|
||||
],
|
||||
|
||||
@@ -11,6 +11,25 @@
|
||||
//
|
||||
// `api` is the coarse contract version (bumped only on a breaking re-shape, which
|
||||
// would be a v2 mount); `server` is the informational package version.
|
||||
//
|
||||
// ── `capabilities` (Phase 14a) ──
|
||||
//
|
||||
// Opaque strings naming what CORE serves beyond the surface every backend has —
|
||||
// the same idea as a module's `capabilities` on GET /public/modules, and
|
||||
// deliberately the same word, so a client feature-detects one way rather than
|
||||
// two. They are a different LIST because core is not a module: publishing core
|
||||
// as a pseudo-module would leave a client unable to tell "this backend has
|
||||
// events" from "a module called core happens to be installed", which is exactly
|
||||
// the distinction the loader exists to make.
|
||||
//
|
||||
// The value is in what is ABSENT. A backend released before Events answers this
|
||||
// object with no `capabilities` key at all, so a client can tell an older site
|
||||
// from one that simply has nothing on its calendar — which it could not do by
|
||||
// probing /public/events, where "not built" and "temporarily down" look alike.
|
||||
//
|
||||
// Static, because these are compiled-in features rather than installed ones:
|
||||
// a core that has these routes always has them. An unknown string is to be
|
||||
// treated as absent, exactly as MODULE_API.md §2.1 says of a module's.
|
||||
|
||||
const pkg = require('../../package.json')
|
||||
|
||||
@@ -18,4 +37,6 @@ module.exports = {
|
||||
service: 'runic-gateway', // stable backend identifier for first-run detection
|
||||
api: 'v1', // API contract version (matches the /api/v1 mount)
|
||||
server: pkg.version || '0.0.0', // server package version (informational)
|
||||
// What core serves beyond the baseline. See the note above.
|
||||
capabilities: ['events'],
|
||||
}
|
||||
|
||||
@@ -332,23 +332,29 @@ const SEEDS = [
|
||||
// single absent variable renders nothing, in both parts). A standalone event
|
||||
// has no `seriesName` and its line disappears rather than reading "Part of .".
|
||||
//
|
||||
// **No `{{actionUrl}}` and no button, deliberately.** There is no public event
|
||||
// page until Phase 14, so the six public triggers declare no `url` variable at
|
||||
// all (see `coreTriggers.js`), and a button here would render as an inert grey
|
||||
// label in every mail — worse than none, because it advertises a link the
|
||||
// reader cannot follow. Phase 14 adds the variable and the block together.
|
||||
// **The button arrived with the page it points at** (Phase 14a). Until then
|
||||
// there was no public event page, the six public triggers declared no url
|
||||
// variable, and a button here would have rendered as an inert grey label in
|
||||
// every mail — worse than no button, because it advertises a link the reader
|
||||
// cannot follow. `eventUrl` is optional and `email.button` drops itself when
|
||||
// its url interpolates to nothing, so an event that is not public still mails
|
||||
// correctly: the block disappears rather than degrading.
|
||||
{
|
||||
key: 'notify.event-started',
|
||||
name: 'Event starting',
|
||||
channel: 'email',
|
||||
protected: false,
|
||||
seedVersion: 1,
|
||||
// Bumped with the button. A deployment whose operator has not customized
|
||||
// this template gets the new one; one that has is left alone and reported as
|
||||
// stale, which is the whole mechanism.
|
||||
seedVersion: 2,
|
||||
subject: '{{title}} is starting',
|
||||
variables: [
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion' },
|
||||
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.' },
|
||||
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign' },
|
||||
{ name: 'startsAtLabel', type: 'string', required: false, example: 'Saturday 12 September at 8:00 pm (America/New_York)' },
|
||||
{ name: 'eventUrl', type: 'string', required: false, example: '/site/events/the-yew-invasion?run=3692' },
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
@@ -356,6 +362,7 @@ const SEEDS = [
|
||||
text('summary', '{{summary}}'),
|
||||
text('when', '{{startsAtLabel}}', { muted: true }),
|
||||
text('series', '{{seriesName}}', { muted: true }),
|
||||
button('open', 'Read more', '{{eventUrl}}', 'Read more about it here:'),
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
|
||||
],
|
||||
|
||||
@@ -92,10 +92,32 @@ async function baseFor(run) {
|
||||
summary: definition.summary || undefined,
|
||||
seriesName: definition.series_name || undefined,
|
||||
timezone: run.timezone || definition.timezone || undefined,
|
||||
eventUrl: eventUrl(definition, run),
|
||||
definition,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The public page for one occurrence (Phase 14a).
|
||||
*
|
||||
* **Site-relative, and it carries the run.** The page lives at the definition's
|
||||
* slug — one stable address for a weekly event, which is what makes a link in
|
||||
* Discord survive a retitle — so the occurrence has to be in the query string or
|
||||
* a mail about last Friday's invasion would open next Friday's.
|
||||
*
|
||||
* **`undefined` when the event is not public**, rather than a path that answers
|
||||
* 404. An unlisted or not-yet-`ready` definition has no page, and `eventUrl` is
|
||||
* declared optional precisely so its block can disappear from a template instead
|
||||
* of rendering a dead button. That is `news.post`'s lesson applied before it
|
||||
* costs anything: a link nobody can follow is worse than no link, because it
|
||||
* advertises one.
|
||||
*/
|
||||
function eventUrl(definition, run) {
|
||||
if (!definition.slug) return undefined
|
||||
if (definition.state !== 'ready' || !definition.listed) return undefined
|
||||
return `/site/events/${encodeURIComponent(definition.slug)}?run=${run.id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire one lifecycle trigger.
|
||||
*
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -69,6 +69,10 @@ const shapeDefinition = (d) => ({
|
||||
concurrencyKey: d.concurrency_key,
|
||||
graceSeconds: d.grace_seconds,
|
||||
timezone: d.timezone,
|
||||
// Whether the public calendar announces it (Phase 14a). Not whether it may
|
||||
// run — an unlisted event schedules and runs exactly as a listed one does,
|
||||
// and is on THIS screen either way.
|
||||
listed: Boolean(d.listed),
|
||||
spec: d.spec,
|
||||
createdAt: d.created_at,
|
||||
updatedAt: d.updated_at,
|
||||
|
||||
29
server/src/router/v1/player/events.controller.js
Normal file
29
server/src/router/v1/player/events.controller.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// Player · Events — the one handler behind /player/events/history (Phase 14a).
|
||||
//
|
||||
// Self-scoped on `req.user.id` and on nothing the caller sent. The model does
|
||||
// the same joins the public surface does — rehearsals and unlisted events are
|
||||
// absent — so a participant cannot learn from their own history that an
|
||||
// unannounced event exists.
|
||||
|
||||
const events = require('../../../model/events/eventPublic.model')
|
||||
const log = require('../../../utils/logger')('player:events')
|
||||
|
||||
async function getHistory(req, res) {
|
||||
try {
|
||||
// A non-integer cursor is dropped rather than bound. `Number('abc')` is NaN,
|
||||
// and NaN reaching a placeholder is a driver-level failure — a 500 for what
|
||||
// is a malformed query string, and the honest answer to one is the first
|
||||
// page.
|
||||
const cursor = Number(req.query.before)
|
||||
const result = await events.history(req.user.id, {
|
||||
limit: req.query.limit ? Number(req.query.limit) : undefined,
|
||||
before: Number.isInteger(cursor) && cursor > 0 ? cursor : null,
|
||||
})
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.error('participation history failed', { message: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getHistory }
|
||||
39
server/src/router/v1/player/events.router.js
Normal file
39
server/src/router/v1/player/events.router.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Player · Events — this account's participation history (EVENTS.md § API
|
||||
// surface, Phase 14a). Mounted at /api/v1/player/events by player/index.js.
|
||||
//
|
||||
// The group gate is `requireAuth` and it is the whole gate: this is role-agnostic
|
||||
// self-service, like the rest of /player. Staff are a superset of players (see
|
||||
// player/index.js), and an admin reading their own attendance is exactly as
|
||||
// ordinary as a player doing it.
|
||||
//
|
||||
// **No backtick in a `#swagger.parameters` annotation.** Unlike `#swagger.summary`
|
||||
// and `#swagger.description`, which are plain strings, a parameters annotation is
|
||||
// parsed as an object literal — a backtick inside its quoted `description` is
|
||||
// rewritten as a quote, and swagger-autogen then DROPS the whole annotation with a
|
||||
// syntax error rather than failing the build.
|
||||
//
|
||||
// **There is no id parameter, deliberately.** The history is `req.user.id`'s and
|
||||
// nothing else's; a route that took a user id would be one middleware mistake
|
||||
// away from publishing who attended what, which is a question about people
|
||||
// rather than about events.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./events.controller')
|
||||
|
||||
const eventsRouter = express.Router()
|
||||
|
||||
eventsRouter.get(
|
||||
'/history',
|
||||
// #swagger.tags = ['Player · Events']
|
||||
// #swagger.summary = 'This account’s event participation'
|
||||
// #swagger.description = 'The events this account took part in, most recent first — the run, when it was, the score a module reported, and the rank once results were published. `rank` is null until then, which is a real state rather than an error. Rehearsals and unlisted events are absent, the same rule the public calendar follows. Keyset paging: pass the last entry’s `id` as `before`.'
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 200 (default 50).' }
|
||||
// #swagger.parameters['before'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Cursor: the id of the last entry on the previous page.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Participation history', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerEventHistory" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not signed in', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.getHistory,
|
||||
)
|
||||
|
||||
module.exports = eventsRouter
|
||||
@@ -27,6 +27,7 @@ const noindex = require('../../../middleware/noindex')
|
||||
const appealsRouter = require('./appeals.router')
|
||||
const teamsRouter = require('./teams.router')
|
||||
const teamForumRouter = require('./teamForum.router')
|
||||
const eventsRouter = require('./events.router')
|
||||
|
||||
const playerRouter = express.Router()
|
||||
|
||||
@@ -39,6 +40,9 @@ const playerRouter = express.Router()
|
||||
playerRouter.use(noindex, requireAuth)
|
||||
|
||||
playerRouter.use('/appeals', appealsRouter)
|
||||
// This account's own event participation (Phase 14a). Self-scoped on
|
||||
// req.user.id, like everything else in this group.
|
||||
playerRouter.use('/events', eventsRouter)
|
||||
playerRouter.use('/teams', teamsRouter)
|
||||
// Same prefix, second router. The forum and the leader-exercised grant flow are a
|
||||
// different capability from "the caller's own Teams", and splitting them keeps
|
||||
|
||||
68
server/src/router/v1/public/events.controller.js
Normal file
68
server/src/router/v1/public/events.controller.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// Public · Events — the anonymous event surface (EVENTS.md § API surface).
|
||||
//
|
||||
// Phase 14a. Three reads and no writes: the calendar, one event, one arc.
|
||||
//
|
||||
// **Every one of them is a thin pass-through to `eventPublic.model`, and that is
|
||||
// deliberate.** The projection — which fields exist at all on a public entry — is
|
||||
// the security boundary, and it belongs in one file rather than in three
|
||||
// controllers that would each have to remember it. What is left here is the
|
||||
// HTTP: parse the query, map the model's `status` onto a response code, and turn
|
||||
// a thrown read into a 500 rather than a stack trace.
|
||||
//
|
||||
// **A 404 here means "no such public event"** and cannot be told from "no such
|
||||
// slug at all". A draft, an archived definition and an unlisted one answer
|
||||
// identically, which is the whole point: an operator who has not announced
|
||||
// something has not announced its existence either.
|
||||
|
||||
const events = require('../../../model/events/eventPublic.model')
|
||||
const log = require('../../../utils/logger')('public:events')
|
||||
|
||||
const fail = (res, err, what) => {
|
||||
log.error(`${what} failed`, { message: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
const answer = (res, result) =>
|
||||
result.ok
|
||||
? res.json(result)
|
||||
: res.status(result.status || 400).json({ message: result.errors?.[0] || 'Bad Request', errors: result.errors })
|
||||
|
||||
async function getCalendar(req, res) {
|
||||
try {
|
||||
const seriesId = req.query.seriesId ? Number(req.query.seriesId) : null
|
||||
if (req.query.seriesId && !Number.isInteger(seriesId)) {
|
||||
return res.status(400).json({ message: 'seriesId must be an integer' })
|
||||
}
|
||||
const result = await events.calendar({
|
||||
from: req.query.from || null,
|
||||
to: req.query.to || null,
|
||||
seriesId,
|
||||
})
|
||||
return answer(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'public calendar')
|
||||
}
|
||||
}
|
||||
|
||||
async function getEvent(req, res) {
|
||||
try {
|
||||
// `run` is optional and un-validated beyond being carried through as a
|
||||
// string: the model matches it against this definition's own runs and
|
||||
// ignores anything else, so a garbage value renders the page rather than an
|
||||
// error. See the model's note on why it is not refused.
|
||||
const result = await events.event(req.params.slug, { runId: req.query.run || null })
|
||||
return answer(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'public event')
|
||||
}
|
||||
}
|
||||
|
||||
async function getSeries(req, res) {
|
||||
try {
|
||||
return answer(res, await events.series(req.params.slug))
|
||||
} catch (err) {
|
||||
return fail(res, err, 'public series')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getCalendar, getEvent, getSeries }
|
||||
60
server/src/router/v1/public/events.router.js
Normal file
60
server/src/router/v1/public/events.router.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Public · Events — mounted at /api/v1/public/events by public/index.js.
|
||||
//
|
||||
// No group gate: this is the anonymous surface, and `siteMode` is applied per
|
||||
// route as it is everywhere else in this tier — during maintenance only an admin
|
||||
// with a valid session sees content.
|
||||
//
|
||||
// Declaration order: '/' is literal and precedes ':slug', and 'series/:slug' is
|
||||
// declared BEFORE ':slug' although it could not be shadowed by it (two segments
|
||||
// against one). It stays above so the relationship is visible to whoever adds
|
||||
// the next route here — and because the one route bug this feature has already
|
||||
// shipped was exactly a static/dynamic ranking surprise, one tier up in React
|
||||
// Router (see App.jsx's note above `events/:id`).
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./events.controller')
|
||||
const siteMode = require('../../../middleware/siteMode')
|
||||
|
||||
const eventsRouter = express.Router()
|
||||
|
||||
eventsRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Public · Events']
|
||||
// #swagger.summary = 'The public event calendar'
|
||||
// #swagger.description = 'Upcoming, live and recent events in a window, ascending by instant. An entry is one of two things and says which: a `run` is a materialised occurrence, and a `projected` entry is arithmetic past the materialisation horizon — a forecast, with nothing committed to it, which a client should draw as such. Instants are UTC and each entry carries the EVENT\'s own timezone, because a shard-local 8pm means the shard\'s evening to everyone reading it; the reader\'s own zone places the entry in a month grid. Rehearsals and unlisted events are absent. Defaults to now through 31 days out; the window may span at most 92 days.'
|
||||
// #swagger.parameters['from'] = { in: 'query', required: false, schema: { type: 'string', format: 'date-time' }, description: 'Window start (ISO). Defaults to now.' }
|
||||
// #swagger.parameters['to'] = { in: 'query', required: false, schema: { type: 'string', format: 'date-time' }, description: 'Window end (ISO). Defaults to 31 days after the start.' }
|
||||
// #swagger.parameters['seriesId'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Restrict to one arc.' }
|
||||
/* #swagger.responses[200] = { description: 'The calendar', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEventCalendar" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Bad window', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
ctrl.getCalendar,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/series/:slug',
|
||||
// #swagger.tags = ['Public · Events']
|
||||
// #swagger.summary = 'One arc'
|
||||
// #swagger.description = 'A series and the listed events in it, in the order an editor arranged them. A series with no listed events answers 404 rather than an empty page: the arc is a label on its definitions, so a page for an empty one would publish the fact that an operator has named something they have not announced.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The series slug.' }
|
||||
/* #swagger.responses[200] = { description: 'The arc', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEventSeries" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such arc, or nothing in it is listed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
ctrl.getSeries,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/:slug',
|
||||
// #swagger.tags = ['Public · Events']
|
||||
// #swagger.summary = 'One event'
|
||||
// #swagger.description = 'The storyline, the arc it belongs to, what is live, what is next, what happened recently, and a results table once one has been published. A draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. The plan behind the event — phases, steps, actions and their params — is never published; a live run carries the LABEL of the phase it is in and nothing more.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The event slug.' }
|
||||
// #swagger.parameters['run'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Which occurrence the results are about — what an announcement\'s link carries, so a mail about last Friday does not open next Friday\'s. A run that does not belong to this event is ignored rather than refused.' }
|
||||
/* #swagger.responses[200] = { description: 'The event', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEvent" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such public event', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
ctrl.getEvent,
|
||||
)
|
||||
|
||||
module.exports = eventsRouter
|
||||
@@ -22,6 +22,7 @@ const wikiRouter = require('./wiki.router')
|
||||
const pagesRouter = require('./pages.router')
|
||||
const modulesRouter = require('./modules.router')
|
||||
const teamsRouter = require('./teams.router')
|
||||
const eventsRouter = require('./events.router')
|
||||
const engagementRouter = require('./engagement.router')
|
||||
const siteRouter = require('./site.router')
|
||||
|
||||
@@ -42,6 +43,10 @@ publicRouter.use('/modules', modulesRouter)
|
||||
// is what populates it (TEAMS.md §10.3). Site-mode gated per route, like the
|
||||
// content above it.
|
||||
publicRouter.use('/teams', teamsRouter)
|
||||
// Events. A core prefix like /teams: the calendar, the event page and the arc
|
||||
// are core's surface even when every step an event dispatches belongs to a
|
||||
// module. Site-mode gated per route, like the content above it.
|
||||
publicRouter.use('/events', eventsRouter)
|
||||
// The unauthenticated half of the engagement system: today exactly the
|
||||
// unsubscribe pair. Its own prefix rather than a Teams sub-path, because what a
|
||||
// token names is a channel and a scope and a scope is not always a Team
|
||||
|
||||
Reference in New Issue
Block a user