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
79 lines
3.2 KiB
JavaScript
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 }
|