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:
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
|
||||
|
||||
Reference in New Issue
Block a user