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