Files
website/server/src/model/events/eventSeries.db.js
wtclaude 1667e636bd 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
2026-09-08 06:18:38 -05:00

79 lines
3.2 KiB
JavaScript

// ── event_series — SQL only ────────────────────────────────────────────────
//
// EVENTS.md §D. The arc a definition may belong to. Phase 1 needed only the
// reads — `event_definitions.series_id` is a foreign key and the definition save
// path has to check it resolves — and Phase 4 adds the writes, because the
// calendar is what makes an arc visible and a form cannot offer a value nobody
// can create.
//
// `ordering` here places a SERIES among the others on the calendar. A
// definition's place WITHIN its arc is `event_definitions.series_order`, which
// is the column an editor drags; the two are deliberately different columns on
// different tables and the schema comment says so.
const { query } = require('../../utils/db')
// `definition_count` is a correlated subquery rather than a join with a GROUP BY:
// the list is a handful of rows, and the delete path needs the same number to
// tell an operator what they are about to detach.
const SELECT_LIST = `
SELECT s.*,
(SELECT COUNT(*) FROM event_definitions d WHERE d.series_id = s.id) AS definition_count
FROM event_series s
`
const list = async () => query(`${SELECT_LIST} ORDER BY s.ordering, s.name, s.id`)
const getById = async (id) => {
const [row] = await query(`${SELECT_LIST} WHERE s.id = ?`, [id])
return row || null
}
const getBySlug = async (slug) => {
const [row] = await query(`${SELECT_LIST} WHERE s.slug = ?`, [slug])
return row || null
}
const exists = async (id) => {
const [row] = await query('SELECT id FROM event_series WHERE id = ?', [id])
return Boolean(row)
}
/** Does any OTHER series hold this slug? The uniqueness pre-check. */
const slugTaken = async (slug, exceptId = null) => {
const rows = exceptId
? await query('SELECT id FROM event_series WHERE slug = ? AND id <> ?', [slug, exceptId])
: await query('SELECT id FROM event_series WHERE slug = ?', [slug])
return rows.length > 0
}
const insert = async (s) => {
const result = await query(
`INSERT INTO event_series (name, slug, description, ordering, created_by)
VALUES (?, ?, ?, ?, ?)`,
[s.name, s.slug, s.description, s.ordering, s.created_by],
)
return Number(result.insertId)
}
const update = (id, s) =>
query(
`UPDATE event_series SET name = ?, slug = ?, description = ?, ordering = ? WHERE id = ?`,
[s.name, s.slug, s.description, s.ordering, id],
)
/**
* A hard delete, and the one place in this feature that is one.
*
* A series is a label rather than authored content: nothing pins one, no run
* references one, and `event_definitions.series_id` is `ON DELETE SET NULL`, so
* removing a series detaches its definitions and destroys nothing. That is why
* it is not archived the way a definition is — an archived label would be a
* state every calendar query has to remember for no benefit. The model answers
* with how many definitions were detached, so the operator learns what happened
* rather than discovering it on the calendar.
*/
const remove = (id) => query('DELETE FROM event_series WHERE id = ?', [id])
module.exports = { list, getById, getBySlug, exists, slugTaken, insert, update, remove }