// ── Event series — the arc ───────────────────────────────────────────────── // // EVENTS.md §D and §I. "Royal Spy Mission → Risky Partner → Message From the // Void" is continuity that exists nowhere in the tooling this feature replaces // (§ "What the real calendar shows, and what it is missing": *no series or // recurrence field*). One small table buys it, and this is the policy half. // // **Why the writes are `admin, editor` and not `admin`.** A series is authoring, // and it is the same act as writing the definition that goes in it — §N2's // narrow gate is about *committing the deployment to a run* (publish, start), // which naming an arc does not do. An editor who can write the events but not // the arc they belong to would have to ask an admin to type a title. // // **A slug is derived once and then frozen**, exactly as a definition's is: the // public arc page lives at `/events/series/:slug` (Phase 14), and a slug that // moved would break every link to it. Renaming the series is free. const db = require('./eventSeries.db') const { slugify, uniqueSlug } = require('../teams/teamSlug') const MAX_NAME = 160 const MAX_DESCRIPTION = 2000 const trimOrNull = (v, max) => { if (v === undefined || v === null) return null const s = String(v).trim() return s === '' ? null : s.slice(0, max) } const list = () => db.list() const getById = (id) => db.getById(id) async function validate(input, { existing = null } = {}) { const errors = [] const body = input && typeof input === 'object' ? input : {} const name = trimOrNull(body.name, MAX_NAME) if (!name) errors.push('name is required') const description = trimOrNull(body.description, MAX_DESCRIPTION) const orderingRaw = body.ordering === undefined ? (existing?.ordering ?? 0) : body.ordering const ordering = Number(orderingRaw) if (!Number.isInteger(ordering) || ordering < 0 || ordering > 9999) { errors.push('ordering must be an integer 0..9999') } if (errors.length) return { ok: false, errors } return { ok: true, series: { name, description, ordering } } } async function create(input, userId) { const checked = await validate(input) if (!checked.ok) return { ok: false, status: 400, errors: checked.errors } // The taken set is read here rather than inside `uniqueSlug` because that // helper is pure — the same shape the team and definition paths use. const taken = (await db.list()).map((s) => s.slug) const slug = uniqueSlug(checked.series.name, taken, { fallback: 'series' }) const id = await db.insert({ ...checked.series, slug, created_by: userId || null }) return { ok: true, status: 201, series: await db.getById(id) } } async function update(id, input, userId) { const existing = await db.getById(id) if (!existing) return { ok: false, status: 404, errors: ['no such series'] } const checked = await validate(input, { existing }) if (!checked.ok) return { ok: false, status: 400, errors: checked.errors } // The slug is the existing one, deliberately: renaming a series must not move // the address its arc page lives at. await db.update(id, { ...checked.series, slug: existing.slug }) return { ok: true, status: 200, series: await db.getById(id) } } /** * Delete a series, detaching whatever belonged to it. * * The count comes back so the caller can say *"3 events were detached"* rather * than leaving an operator to notice on the calendar. `series_id` is * `ON DELETE SET NULL`, so nothing is destroyed and re-attaching is a dropdown. */ async function remove(id) { const existing = await db.getById(id) if (!existing) return { ok: false, status: 404, errors: ['no such series'] } await db.remove(id) return { ok: true, status: 200, detached: Number(existing.definition_count || 0) } } module.exports = { list, getById, validate, create, update, remove, slugify, MAX_NAME }