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