feat(events): schedule, recurrence and the calendar (Phase 4)
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>
This commit is contained in:
93
server/src/model/events/eventSeries.model.js
Normal file
93
server/src/model/events/eventSeries.model.js
Normal file
@@ -0,0 +1,93 @@
|
||||
// ── 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 }
|
||||
Reference in New Issue
Block a user