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'],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user