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:
@@ -991,6 +991,13 @@ const doc = {
|
||||
service: { type: 'string', example: 'runic-gateway', description: 'Stable backend identifier for first-run recognition.' },
|
||||
api: { type: 'string', example: 'v1', description: 'API contract version (matches the /api/v1 mount).' },
|
||||
server: { type: 'string', example: '1.0.0', description: 'Server package version (informational).' },
|
||||
capabilities: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['events'],
|
||||
description:
|
||||
'Opaque strings naming what CORE serves beyond the baseline every backend has — the same idea as a module’s `capabilities` on /public/modules, and a separate list because core is not a module. A backend released before a capability existed omits the key entirely, which is how a client tells an older site from one that simply has nothing to show. Treat an unknown string as absent.',
|
||||
},
|
||||
},
|
||||
},
|
||||
PublicModules: {
|
||||
@@ -1234,6 +1241,190 @@ const doc = {
|
||||
},
|
||||
},
|
||||
},
|
||||
PublicEventEntry: {
|
||||
type: 'object',
|
||||
description:
|
||||
'One calendar entry. `kind` says which of two things it is: a `run` is a materialised occurrence, a `projected` entry is arithmetic past the materialisation horizon — a forecast with nothing committed to it, which a client should draw as such.',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['run', 'projected'], example: 'run' },
|
||||
title: { type: 'string', example: 'The Yew Invasion' },
|
||||
slug: { type: 'string', example: 'the-yew-invasion' },
|
||||
seriesName: { type: 'string', nullable: true, example: 'The Yew Campaign' },
|
||||
seriesSlug: { type: 'string', nullable: true, example: 'the-yew-campaign' },
|
||||
scheduledFor: { type: 'string', format: 'date-time', description: 'The instant, UTC.' },
|
||||
timezone: {
|
||||
type: 'string',
|
||||
example: 'America/New_York',
|
||||
description:
|
||||
'The EVENT’s zone, not the reader’s. A shard-local 8pm means the shard’s evening to everyone reading it, so the time is rendered in this zone while the reader’s own zone places the entry in a month grid.',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['scheduled', 'live', 'completed', 'cancelled'],
|
||||
description:
|
||||
'The public status word. `failed` and `missed` are both published as `cancelled`: to a visitor they are one event — it was on the calendar and it did not happen — while the difference between them is about the deployment rather than about the event.',
|
||||
},
|
||||
live: { type: 'boolean', example: false },
|
||||
adjusted: {
|
||||
type: 'boolean',
|
||||
description: 'Projections only: this instant is not the wall clock the schedule names, because a DST change moved it.',
|
||||
},
|
||||
shiftMinutes: { type: 'integer', description: 'Projections only: by how much.' },
|
||||
},
|
||||
},
|
||||
PublicEventCalendar: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean', example: true },
|
||||
window: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string', format: 'date-time' },
|
||||
to: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
entries: { type: 'array', items: { $ref: '#/components/schemas/PublicEventEntry' } },
|
||||
truncated: { type: 'boolean', description: 'The window held more entries than the cap.', example: false },
|
||||
},
|
||||
},
|
||||
PublicEventOccurrence: {
|
||||
type: 'object',
|
||||
description:
|
||||
'One occurrence of an event, as a public page lists it. Health, cleanup state, claims and errors are never published — a degraded run is a fact about the deployment’s plumbing, while "the event is running" is the fact about the event.',
|
||||
properties: {
|
||||
runId: { type: 'integer', example: 3692 },
|
||||
scheduledFor: { type: 'string', format: 'date-time' },
|
||||
timezone: { type: 'string', example: 'America/New_York' },
|
||||
startedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
endedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
status: { type: 'string', enum: ['scheduled', 'live', 'completed', 'cancelled'] },
|
||||
live: { type: 'boolean' },
|
||||
scope: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Module-opaque — the shard or server this occurrence ran on, on a deployment that uses them.',
|
||||
},
|
||||
phase: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
example: 'The assault',
|
||||
description:
|
||||
'The LABEL of the phase a live run is in, resolved from the version the run pinned. Null unless it is live. The plan behind the event — phases, steps, actions and their params — is never published.',
|
||||
},
|
||||
resultsPublishedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
},
|
||||
},
|
||||
PublicEventParticipant: {
|
||||
type: 'object',
|
||||
description:
|
||||
'A results row. The member key is the game’s own identifier for a character and is module-opaque, so core cannot say what publishing one would disclose — it is not published. A display name is whatever the module chose to put in `meta`.',
|
||||
properties: {
|
||||
name: { type: 'string', nullable: true, example: 'Aldric' },
|
||||
score: { type: 'number', example: 1420 },
|
||||
rank: { type: 'integer', nullable: true, example: 3, description: 'Null until results were published.' },
|
||||
meta: { type: 'object', nullable: true, description: 'Module-opaque: whatever it wanted shown beside a name.' },
|
||||
},
|
||||
},
|
||||
PublicEvent: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean', example: true },
|
||||
event: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', example: 'The Yew Invasion' },
|
||||
slug: { type: 'string', example: 'the-yew-invasion' },
|
||||
summary: { type: 'string', nullable: true },
|
||||
body: { type: 'string', nullable: true, description: 'The storyline. Sanitized HTML, the treatment a wiki page gets.' },
|
||||
imageUrl: { type: 'string', nullable: true },
|
||||
timezone: { type: 'string', example: 'America/New_York' },
|
||||
series: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: { name: { type: 'string' }, slug: { type: 'string' } },
|
||||
},
|
||||
live: { type: 'boolean' },
|
||||
current: { $ref: '#/components/schemas/PublicEventOccurrence' },
|
||||
next: { $ref: '#/components/schemas/PublicEventOccurrence' },
|
||||
upcoming: { type: 'array', items: { $ref: '#/components/schemas/PublicEventOccurrence' } },
|
||||
past: { type: 'array', items: { $ref: '#/components/schemas/PublicEventOccurrence' } },
|
||||
results: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description:
|
||||
'Present only once an occurrence has published results. Which occurrence follows `?run=`, then a live one, then the most recent that published any.',
|
||||
properties: {
|
||||
runId: { type: 'integer' },
|
||||
scheduledFor: { type: 'string', format: 'date-time' },
|
||||
publishedAt: { type: 'string', format: 'date-time' },
|
||||
participants: { type: 'array', items: { $ref: '#/components/schemas/PublicEventParticipant' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
PublicEventSeries: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean', example: true },
|
||||
series: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', example: 'The Yew Campaign' },
|
||||
slug: { type: 'string', example: 'the-yew-campaign' },
|
||||
description: { type: 'string', nullable: true },
|
||||
events: {
|
||||
type: 'array',
|
||||
description: 'The listed events in the arc, in the order an editor arranged them.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
slug: { type: 'string' },
|
||||
summary: { type: 'string', nullable: true },
|
||||
imageUrl: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
PlayerEventHistory: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean', example: true },
|
||||
entries: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', description: 'The participation row. Pass the last one as `before` to page.' },
|
||||
runId: { type: 'integer' },
|
||||
title: { type: 'string' },
|
||||
slug: { type: 'string' },
|
||||
seriesName: { type: 'string', nullable: true },
|
||||
seriesSlug: { type: 'string', nullable: true },
|
||||
scheduledFor: { type: 'string', format: 'date-time' },
|
||||
startedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
endedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
timezone: { type: 'string' },
|
||||
status: { type: 'string', enum: ['scheduled', 'live', 'completed', 'cancelled'] },
|
||||
joinedAt: { type: 'string', format: 'date-time' },
|
||||
score: { type: 'number' },
|
||||
rank: {
|
||||
type: 'integer',
|
||||
nullable: true,
|
||||
description: 'Null until results were published — a real state rather than an error.',
|
||||
},
|
||||
resultsPublishedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
meta: { type: 'object', nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
PublicTeamMember: {
|
||||
type: 'object',
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user