The four closed recurrence shapes computed in the definition's own IANA zone, a fourteen-day materialisation horizon with projections beyond it, series as a managed thing, and the admin calendar that replaces the plugin this feature exists to replace. An event now happens on its own. No schema change: Phase 1 built every column this needed. - events/recurrence.js is the ONE place an occurrence is computed, so the runner's expansion and the calendar's forecast cannot disagree. No date library added — Node ships the tzdata one would vendor, behind Intl. - The runner's materialise leg is now two halves: expand, then sweep. The window starts at `now - grace`, so an occurrence nobody could have seen is never invented retroactively; the horizon is what makes the missed sweep mean anything for a recurrence. - Publishing is the schedule switch and archiving turns it off, and publishing re-pins every occurrence that has not started. - A projection is never drawn over an instant a run occupies, so a cancelled occurrence does not reappear as a forecast. 54 new tests, incl. the DST fixture set the plan asked for and three new statements proved against a real MariaDB. Suite 1768/1711/56 skipped/1 fail (pre-existing CRLF). Walked end to end on the local review stack. Docs: RunicGateway/docs#PENDING Co-Authored-By: Claude <noreply@anthropic.com>
94 lines
3.9 KiB
JavaScript
94 lines
3.9 KiB
JavaScript
// ── 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 }
|