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:
2026-09-08 06:18:38 -05:00
parent 6e6c24065c
commit 1667e636bd
39 changed files with 3964 additions and 45 deletions

View File

@@ -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,

View 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 }

View 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 accounts 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 entrys `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

View File

@@ -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

View 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 }

View 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

View File

@@ -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