// ── event_definitions — SQL only ─────────────────────────────────────────── // // EVENTS.md §D. The `.db.js` half of the pair: parameterised SQL and hydration, // no policy. Everything that decides whether a write is allowed lives in // `eventDefinitions.model.js`. const { query } = require('../../utils/db') const { parseJson } = require('./eventJson') const hydrate = (row) => row && { ...row, spec: parseJson(row.spec, null), } // `current_version` is joined rather than stored: the list screen shows "v3" and // the column that would hold it is a denormalisation of a row this query already // has to reach for the publish date anyway. const SELECT_LIST = ` SELECT d.*, s.name AS series_name, s.slug AS series_slug, v.version AS current_version FROM event_definitions d LEFT JOIN event_series s ON s.id = d.series_id LEFT JOIN event_versions v ON v.id = d.current_version_id ` const list = async ({ state = null } = {}) => { const rows = state ? await query(`${SELECT_LIST} WHERE d.state = ? ORDER BY d.updated_at DESC, d.id DESC`, [state]) : await query(`${SELECT_LIST} ORDER BY d.updated_at DESC, d.id DESC`) return rows.map(hydrate) } const getById = async (id) => { const [row] = await query(`${SELECT_LIST} WHERE d.id = ?`, [id]) return hydrate(row) } const getBySlug = async (slug) => { const [row] = await query(`${SELECT_LIST} WHERE d.slug = ?`, [slug]) return hydrate(row) } /** Does any OTHER definition hold this slug? The uniqueness pre-check. */ const slugTaken = async (slug, exceptId = null) => { const rows = exceptId ? await query('SELECT id FROM event_definitions WHERE slug = ? AND id <> ?', [slug, exceptId]) : await query('SELECT id FROM event_definitions WHERE slug = ?', [slug]) return rows.length > 0 } const insert = async (d) => { const result = await query( `INSERT INTO event_definitions (title, slug, summary, body, image_url, owner_module, series_id, series_order, concurrency_key, grace_seconds, timezone, spec, created_by, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ d.title, d.slug, d.summary, d.body, d.image_url, d.owner_module, d.series_id, d.series_order, d.concurrency_key, d.grace_seconds, d.timezone, JSON.stringify(d.spec), d.created_by, d.created_by, ], ) return result.insertId } const update = (id, d) => query( `UPDATE event_definitions SET title = ?, slug = ?, summary = ?, body = ?, image_url = ?, series_id = ?, series_order = ?, concurrency_key = ?, grace_seconds = ?, timezone = ?, spec = ?, updated_by = ? WHERE id = ?`, [ d.title, d.slug, d.summary, d.body, d.image_url, d.series_id, d.series_order, d.concurrency_key, d.grace_seconds, d.timezone, JSON.stringify(d.spec), d.updated_by, id, ], ) /** * Point a definition at the version it just published, and mark it `ready`. * * One statement, because the two halves are the same fact: `ready` means "a * version has been published and the schedule is live" (§E), so a state without * a `current_version_id` is a lie the scheduler would act on. */ const markReady = (id, versionId, userId) => query( `UPDATE event_definitions SET state = 'ready', current_version_id = ?, updated_by = ? WHERE id = ?`, [versionId, userId, id], ) /** * Every definition the runner should expand a recurrence for (Phase 4). * * `ready` is the whole gate, and it is deliberately the only one: EVENTS.md §E * defines `ready` as "a version has been published and the schedule is live", so * publishing IS the switch and archiving is how an operator turns a recurrence * off. A separate schedule-enabled flag would be a second answer to a question * `state` already answers, and the two would eventually disagree. * * The VERSION's spec is joined rather than the definition's working copy: the * draft is what an author is midway through editing, and a half-typed `weekly` * must never materialise anything. The pinned spec comes back with it, so the * whole expansion is one round trip. * * The series columns are here for the CALENDAR rather than the runner, which * ignores them: a projected occurrence has to be filterable and labellable by * its arc exactly as a materialised run is, and a second query to learn the name * of a row this one already reached would be two round trips for a join. */ const findSchedulable = async () => { const rows = await query( `SELECT d.id, d.title, d.slug, d.timezone, d.grace_seconds, d.concurrency_key, d.current_version_id, d.series_id, v.spec AS version_spec, s.name AS series_name, s.slug AS series_slug FROM event_definitions d JOIN event_versions v ON v.id = d.current_version_id LEFT JOIN event_series s ON s.id = d.series_id WHERE d.state = 'ready' ORDER BY d.id`, ) return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) })) } /** * Archive. Never a hard delete while runs reference it (§ API surface) — and the * schema would refuse one anyway, because `event_runs.version_id` RESTRICTs. * Archiving is what "delete" means on this screen, and the row keeps its history. */ const archive = (id, userId) => query("UPDATE event_definitions SET state = 'archived', updated_by = ? WHERE id = ?", [userId, id]) module.exports = { list, getById, getBySlug, slugTaken, findSchedulable, insert, update, markReady, archive, }