Files
website/server/src/model/events/eventSeries.model.js
wtclaude 6e73660b52
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 13m26s
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>
2026-09-02 16:10:16 -05:00

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 }