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

@@ -116,13 +116,30 @@ const materialise = async (run) => {
* a run records the zone it was COMPUTED in and a definition's zone can be
* edited afterwards.
*/
const listInWindow = async ({ from, to, status = null, scope = null, seriesId = null, limit = 500 } = {}) => {
const listInWindow = async ({
from,
to,
status = null,
scope = null,
seriesId = null,
limit = 500,
publicOnly = false,
} = {}) => {
const where = ['r.scheduled_for >= ?', 'r.scheduled_for < ?']
const args = [from, to]
if (status) {
where.push('r.status = ?')
args.push(status)
}
// The public calendar's two exclusions, in SQL rather than in the model that
// maps the rows. A rehearsal "is excluded from the public calendar and from
// participation history" by §D's own column comment, and an unlisted
// definition is one an operator chose not to announce. Both belong in the
// query because a filter applied after the read is a filter somebody can
// forget in the next caller.
if (publicOnly) {
where.push('r.rehearsal = 0', 'd.listed = 1', "d.state <> 'archived'")
}
if (scope !== null && scope !== undefined) {
where.push('r.scope = ?')
args.push(scope)
@@ -497,6 +514,33 @@ const reclaimStale = async (now) => {
return Number(result?.affectedRows || 0)
}
/**
* One definition's public occurrences, newest first (Phase 14a).
*
* Rehearsals are excluded here rather than by the caller, for `listInWindow`'s
* reason. The definition's own `listed`/`state` are NOT re-checked: the only
* caller has already resolved the definition through `getPublicBySlug`, and a
* second copy of that rule is a second thing to keep in step with the first.
*
* `scheduled` runs come back too — an upcoming occurrence is exactly what a
* visitor came to the page for — and the caller splits past from future on the
* instant rather than on the status, because a `missed` run is in the past
* whatever its status says.
*/
const listPublicForDefinition = async (definitionId, limit = 50) => {
const n = Math.min(Math.max(Number(limit) || 50, 1), 200)
const rows = await query(
`SELECT r.*, v.version AS version_number
FROM event_runs r
JOIN event_versions v ON v.id = r.version_id
WHERE r.definition_id = ? AND r.rehearsal = 0
ORDER BY r.scheduled_for DESC, r.id DESC
LIMIT ${n}`,
[definitionId],
)
return rows.map(hydrate)
}
/** Terminal runs that ended before `before` — what the log retention sweep walks. */
const terminalBefore = async (before, limit = 500) => {
const n = Math.min(Math.max(Number(limit) || 500, 1), 5000)
@@ -516,6 +560,7 @@ module.exports = {
getById,
materialise,
listInWindow,
listPublicForDefinition,
repinScheduled,
listScheduledFor,
findOccurrence,