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:
170
server/src/model/events/eventCalendar.model.js
Normal file
170
server/src/model/events/eventCalendar.model.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// ── The calendar ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §I: "month and list view, filtered by category, scope and series",
|
||||
// and Phase 4's stated deliverable — *the thing this feature exists to replace*
|
||||
// is a WordPress calendar plugin with no series field and no recurrence.
|
||||
//
|
||||
// **A calendar entry is one of two things, and the difference is not cosmetic.**
|
||||
//
|
||||
// - A **run**: a real `event_runs` row. It has an id, a status, a health, a
|
||||
// pinned version and a console. Somebody can cancel it. It exists because the
|
||||
// runner materialised it inside its fourteen-day horizon, or because an admin
|
||||
// started it by hand.
|
||||
// - A **projection**: arithmetic. There is no row, nothing to cancel, and
|
||||
// nothing has been committed to. It exists so that a monthly event is visible
|
||||
// three weeks out instead of the calendar simply ending at the horizon (org
|
||||
// lead, 2026-09-02).
|
||||
//
|
||||
// The API says which each is and the UI renders them differently, because an
|
||||
// operator acting on a projection as though it were a booking is the failure
|
||||
// this distinction exists to prevent. A projection is a forecast of what the
|
||||
// runner *will* materialise, computed by the same `occurrencesBetween` the
|
||||
// runner itself calls — one arithmetic, so the forecast cannot disagree with
|
||||
// what later appears.
|
||||
//
|
||||
// **A projection is never emitted for an instant a run already occupies**, which
|
||||
// is what keeps the fortnight inside the horizon from showing everything twice.
|
||||
// That rule also does the right thing for a CANCELLED occurrence: the row is
|
||||
// still there, so nothing re-projects it, and an event an operator called off
|
||||
// does not reappear on the calendar as though it were still coming.
|
||||
|
||||
const runsDb = require('./eventRuns.db')
|
||||
const definitionsDb = require('./eventDefinitions.db')
|
||||
const recurrence = require('../../events/recurrence')
|
||||
|
||||
// A calendar request is operator-supplied, and a year-wide window across forty
|
||||
// weekly definitions is how a month view becomes an outage. Ninety-two days is
|
||||
// a three-month view — more than the month grid and the list either need.
|
||||
const MAX_WINDOW_DAYS = 92
|
||||
const MAX_ENTRIES = 1000
|
||||
|
||||
const runEntry = (run) => ({
|
||||
kind: 'run',
|
||||
runId: run.id,
|
||||
definitionId: run.definition_id,
|
||||
title: run.definition_title,
|
||||
slug: run.definition_slug,
|
||||
seriesId: run.series_id || null,
|
||||
seriesName: run.series_name || null,
|
||||
seriesSlug: run.series_slug || null,
|
||||
scheduledFor: run.scheduled_for,
|
||||
timezone: run.timezone,
|
||||
scope: run.scope,
|
||||
status: run.status,
|
||||
health: run.health,
|
||||
version: run.version_number,
|
||||
rehearsal: Boolean(run.rehearsal),
|
||||
waitingSteps: Number(run.waiting_steps || 0),
|
||||
})
|
||||
|
||||
const projectedEntry = (definition, occurrence) => ({
|
||||
kind: 'projected',
|
||||
runId: null,
|
||||
definitionId: definition.id,
|
||||
title: definition.title,
|
||||
slug: definition.slug,
|
||||
seriesId: definition.series_id || null,
|
||||
seriesName: definition.series_name || null,
|
||||
seriesSlug: definition.series_slug || null,
|
||||
scheduledFor: occurrence.at,
|
||||
timezone: definition.timezone,
|
||||
scope: '',
|
||||
status: null,
|
||||
health: null,
|
||||
// Why this instant is not the wall clock the schedule names. Carried on the
|
||||
// projection as well as on the materialised run, so the calendar can explain
|
||||
// a DST-shifted time before it happens rather than after.
|
||||
adjusted: occurrence.adjusted,
|
||||
shiftMinutes: occurrence.shiftMinutes,
|
||||
})
|
||||
|
||||
/**
|
||||
* The calendar for a window.
|
||||
*
|
||||
* `{ ok, window, horizon, entries }` — entries ascending by instant, runs and
|
||||
* projections interleaved. `horizon` is the instant past which nothing is
|
||||
* materialised yet, so the UI can draw the line rather than infer it.
|
||||
*
|
||||
* **The instants are UTC and the placement is the client's.** A month grid has
|
||||
* one date axis and the viewer's own zone is what "this month" means to the
|
||||
* person reading it; each entry carries its own `timezone` so the time beside it
|
||||
* reads `20:00 Europe/Berlin` and nobody misreads a shard's local schedule as
|
||||
* their own. That is the split §E's "the timezone belongs to the event" implies:
|
||||
* the event owns the time, the reader owns the calendar.
|
||||
*/
|
||||
async function calendar({
|
||||
from,
|
||||
to,
|
||||
status = null,
|
||||
scope = null,
|
||||
seriesId = null,
|
||||
horizonDays = 14,
|
||||
now = new Date(),
|
||||
} = {}) {
|
||||
const start = from instanceof Date ? from : new Date(from)
|
||||
const end = to instanceof Date ? to : new Date(to)
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return { ok: false, status: 400, errors: ['from and to must be dates'] }
|
||||
}
|
||||
if (end <= start) {
|
||||
return { ok: false, status: 400, errors: ['to must be after from'] }
|
||||
}
|
||||
if (end - start > MAX_WINDOW_DAYS * recurrence.DAY_MS) {
|
||||
return { ok: false, status: 400, errors: [`the window may span at most ${MAX_WINDOW_DAYS} days`] }
|
||||
}
|
||||
|
||||
const runs = await runsDb.listInWindow({ from: start, to: end, status, scope, seriesId })
|
||||
const entries = runs.map(runEntry)
|
||||
|
||||
// Every instant a run already occupies, keyed by definition. Projections are
|
||||
// per definition at the empty scope, so the definition and the instant are the
|
||||
// whole key -- the same triple the unique index uses, with the scope fixed.
|
||||
const taken = new Set(
|
||||
runs
|
||||
.filter((r) => !r.scope)
|
||||
.map((r) => `${r.definition_id}@${new Date(r.scheduled_for).getTime()}`),
|
||||
)
|
||||
|
||||
// A status filter is a filter on RUNS. A projection has no status, so asking
|
||||
// for "everything that failed" must not answer with a forecast — it would be a
|
||||
// forecast that failed, which is not a thing.
|
||||
// The scope filter behaves the same way, and for the same reason: automatic
|
||||
// expansion is at the empty scope (org lead, 2026-09-02), so a request narrowed
|
||||
// to a named scope has no forecast to give.
|
||||
if (!status && !scope) {
|
||||
const definitions = await definitionsDb.findSchedulable()
|
||||
for (const definition of definitions) {
|
||||
if (seriesId && Number(definition.series_id) !== Number(seriesId)) continue
|
||||
const schedule = definition.version_spec?.schedule
|
||||
if (!schedule || schedule.kind === 'manual') continue
|
||||
let occurrences = []
|
||||
try {
|
||||
occurrences = recurrence.occurrencesBetween(
|
||||
schedule,
|
||||
definition.timezone || 'UTC',
|
||||
start,
|
||||
end,
|
||||
)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const occurrence of occurrences) {
|
||||
if (taken.has(`${definition.id}@${occurrence.at.getTime()}`)) continue
|
||||
entries.push(projectedEntry(definition, occurrence))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor))
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
window: { from: start, to: end },
|
||||
horizon: new Date(now.getTime() + horizonDays * recurrence.DAY_MS),
|
||||
entries: entries.slice(0, MAX_ENTRIES),
|
||||
truncated: entries.length > MAX_ENTRIES,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { calendar, MAX_WINDOW_DAYS, MAX_ENTRIES }
|
||||
@@ -114,6 +114,39 @@ const markReady = (id, versionId, userId) =>
|
||||
[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.
|
||||
@@ -127,6 +160,7 @@ module.exports = {
|
||||
getById,
|
||||
getBySlug,
|
||||
slugTaken,
|
||||
findSchedulable,
|
||||
insert,
|
||||
update,
|
||||
markReady,
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
const db = require('./eventDefinitions.db')
|
||||
const versionsDb = require('./eventVersions.db')
|
||||
const runsDb = require('./eventRuns.db')
|
||||
const logDb = require('./eventRunLog.db')
|
||||
const seriesDb = require('./eventSeries.db')
|
||||
const spec = require('../../events/spec')
|
||||
const { slugify, uniqueSlug } = require('../teams/teamSlug')
|
||||
@@ -229,7 +230,31 @@ async function publish(id, userId) {
|
||||
const version = await versionsDb.nextVersion(id)
|
||||
const versionId = await versionsDb.insert(id, version, checked.spec, userId)
|
||||
await db.markReady(id, versionId, userId)
|
||||
return { ok: true, versionId, version, definition: await db.getById(id) }
|
||||
|
||||
// Occurrences already materialised ahead of their instant move to the new
|
||||
// version; ones that have begun do not (org lead, 2026-09-02). Logged per run
|
||||
// rather than only counted, because "which version did this run actually use"
|
||||
// is the first question an audit asks and the pin is no longer immutable while
|
||||
// a run is still `scheduled`.
|
||||
const pending = await runsDb.listScheduledFor(id)
|
||||
const stale = pending.filter((r) => Number(r.version_id) !== Number(versionId))
|
||||
const repinned = stale.length ? await runsDb.repinScheduled(id, versionId) : 0
|
||||
for (const run of stale) {
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'run.status',
|
||||
detail: {
|
||||
to: 'scheduled',
|
||||
repinned: true,
|
||||
fromVersionId: run.version_id,
|
||||
toVersionId: versionId,
|
||||
toVersion: version,
|
||||
by: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true, versionId, version, repinned, definition: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -97,6 +97,92 @@ const materialise = async (run) => {
|
||||
return Number(result?.affectedRows || 0) === 1 ? result.insertId : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every run whose instant falls inside a window — the calendar's real half.
|
||||
*
|
||||
* Ascending, unlike the admin run list: a calendar is read forwards. The join
|
||||
* reaches the series so a month can be filtered to one arc without a second
|
||||
* round trip, and `d.timezone` is NOT what comes back — `r.timezone` is, because
|
||||
* a run records the zone it was COMPUTED in and a definition's zone can be
|
||||
* edited afterwards.
|
||||
*/
|
||||
const listInWindow = async ({ from, to, status = null, scope = null, seriesId = null, limit = 500 } = {}) => {
|
||||
const where = ['r.scheduled_for >= ?', 'r.scheduled_for < ?']
|
||||
const args = [from, to]
|
||||
if (status) {
|
||||
where.push('r.status = ?')
|
||||
args.push(status)
|
||||
}
|
||||
if (scope !== null && scope !== undefined) {
|
||||
where.push('r.scope = ?')
|
||||
args.push(scope)
|
||||
}
|
||||
if (seriesId) {
|
||||
where.push('d.series_id = ?')
|
||||
args.push(seriesId)
|
||||
}
|
||||
const n = Math.min(Math.max(Number(limit) || 500, 1), 1000)
|
||||
const rows = await query(
|
||||
`SELECT r.*, d.title AS definition_title, d.slug AS definition_slug,
|
||||
d.series_id AS series_id, se.name AS series_name, se.slug AS series_slug,
|
||||
v.version AS version_number,
|
||||
(SELECT COUNT(*) FROM event_run_steps s
|
||||
WHERE s.run_id = r.id AND s.status = 'running' AND s.claim_expires_at IS NULL) AS waiting_steps
|
||||
FROM event_runs r
|
||||
JOIN event_definitions d ON d.id = r.definition_id
|
||||
JOIN event_versions v ON v.id = r.version_id
|
||||
LEFT JOIN event_series se ON se.id = d.series_id
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY r.scheduled_for, r.id
|
||||
LIMIT ${n}`,
|
||||
args,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Point every not-yet-started occurrence of a definition at a new version.
|
||||
*
|
||||
* Publishing calls this, and the guard is the whole statement: `status =
|
||||
* 'scheduled'` and `started_at IS NULL`. A run that has begun keeps the version
|
||||
* it pinned, for ever, because that pin is what makes it explicable afterwards
|
||||
* -- and a run that has NOT begun has nothing to explain yet.
|
||||
*
|
||||
* **Why re-pinning is the right answer and doing nothing is not** (org lead,
|
||||
* 2026-09-02): occurrences are materialised a fortnight ahead, so on the day an
|
||||
* editor fixes a typo there are already fourteen days of rows carrying the old
|
||||
* spec. Left alone, the fix reaches none of them, and the operator's only
|
||||
* recourse -- cancelling each one -- is worse: a cancelled row still holds its
|
||||
* slot in `uq_evrun_occurrence`, so the occurrence does not come back on the new
|
||||
* version, it disappears.
|
||||
*
|
||||
* Answers how many were moved, so publish can say so rather than leaving it to
|
||||
* be noticed.
|
||||
*/
|
||||
const repinScheduled = async (definitionId, versionId) => {
|
||||
const result = await query(
|
||||
`UPDATE event_runs
|
||||
SET version_id = ?
|
||||
WHERE definition_id = ?
|
||||
AND status = 'scheduled'
|
||||
AND started_at IS NULL
|
||||
AND version_id <> ?`,
|
||||
[versionId, definitionId, versionId],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
/** The scheduled, not-yet-started occurrences a re-pin would move. */
|
||||
const listScheduledFor = async (definitionId) =>
|
||||
(
|
||||
await query(
|
||||
`SELECT id, version_id, scheduled_for FROM event_runs
|
||||
WHERE definition_id = ? AND status = 'scheduled' AND started_at IS NULL
|
||||
ORDER BY scheduled_for`,
|
||||
[definitionId],
|
||||
)
|
||||
).map(hydrate)
|
||||
|
||||
/** The occurrence the unique key names, whether or not this call created it. */
|
||||
const findOccurrence = async (definitionId, scope, scheduledFor) => {
|
||||
const [row] = await query(
|
||||
@@ -370,6 +456,9 @@ module.exports = {
|
||||
list,
|
||||
getById,
|
||||
materialise,
|
||||
listInWindow,
|
||||
repinScheduled,
|
||||
listScheduledFor,
|
||||
findOccurrence,
|
||||
countActiveForDefinition,
|
||||
findDue,
|
||||
|
||||
@@ -50,9 +50,21 @@ function renderConcurrencyKey(template, params) {
|
||||
*
|
||||
* `scheduledFor` defaults to now — "start now" is an occurrence whose instant is
|
||||
* the present, not a separate concept, which is what keeps the runner's one
|
||||
* materialise/advance path honest when Phase 4 adds recurrence on top.
|
||||
* materialise/advance path honest now that Phase 4 has put recurrence on top.
|
||||
*
|
||||
* **Phase 4's expansion calls this, rather than a second insert path beside it.**
|
||||
* That is deliberate: every check here — the definition is still `ready`, the
|
||||
* version still has phases, the concurrency key renders, the first phase's steps
|
||||
* are materialised with their idempotency keys — is one a scheduled occurrence
|
||||
* needs at least as much as a hand-started one, because there is nobody watching
|
||||
* when it happens. The `INSERT IGNORE` answering `created: false` is what makes
|
||||
* it safe to call on every tick for every occurrence inside the horizon.
|
||||
*/
|
||||
async function create(definitionId, { scope = '', scheduledFor = null, rehearsal = false, params = null } = {}, userId) {
|
||||
async function create(
|
||||
definitionId,
|
||||
{ scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual' } = {},
|
||||
userId,
|
||||
) {
|
||||
const definition = await definitionsDb.getById(definitionId)
|
||||
if (!definition) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (definition.state !== 'ready') {
|
||||
@@ -107,6 +119,11 @@ async function create(definitionId, { scope = '', scheduledFor = null, rehearsal
|
||||
version: version.version,
|
||||
scope: scopeValue,
|
||||
rehearsal: Boolean(rehearsal),
|
||||
// 'manual' is an admin pressing start; 'schedule' is the runner expanding
|
||||
// a recurrence (Phase 4). Both produce the same row, and the log is the
|
||||
// only place the difference is recorded — `started_by` is NULL for both a
|
||||
// scheduled occurrence and one started by a since-deleted account.
|
||||
source,
|
||||
by: userId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
// ── event_series — SQL only ────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D. The arc a definition may belong to. Phase 1 needs the reads —
|
||||
// `event_definitions.series_id` is a foreign key and the definition save path
|
||||
// has to check it resolves — and creating one is Phase 4's, where the calendar
|
||||
// is what makes an arc visible.
|
||||
// EVENTS.md §D. The arc a definition may belong to. Phase 1 needed only the
|
||||
// reads — `event_definitions.series_id` is a foreign key and the definition save
|
||||
// path has to check it resolves — and Phase 4 adds the writes, because the
|
||||
// calendar is what makes an arc visible and a form cannot offer a value nobody
|
||||
// can create.
|
||||
//
|
||||
// `ordering` here places a SERIES among the others on the calendar. A
|
||||
// definition's place WITHIN its arc is `event_definitions.series_order`, which
|
||||
// is the column an editor drags; the two are deliberately different columns on
|
||||
// different tables and the schema comment says so.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const list = async () =>
|
||||
query('SELECT * FROM event_series ORDER BY ordering, name, id')
|
||||
// `definition_count` is a correlated subquery rather than a join with a GROUP BY:
|
||||
// the list is a handful of rows, and the delete path needs the same number to
|
||||
// tell an operator what they are about to detach.
|
||||
const SELECT_LIST = `
|
||||
SELECT s.*,
|
||||
(SELECT COUNT(*) FROM event_definitions d WHERE d.series_id = s.id) AS definition_count
|
||||
FROM event_series s
|
||||
`
|
||||
|
||||
const list = async () => query(`${SELECT_LIST} ORDER BY s.ordering, s.name, s.id`)
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM event_series WHERE id = ?', [id])
|
||||
const [row] = await query(`${SELECT_LIST} WHERE s.id = ?`, [id])
|
||||
return row || null
|
||||
}
|
||||
|
||||
@@ -20,4 +34,40 @@ const exists = async (id) => {
|
||||
return Boolean(row)
|
||||
}
|
||||
|
||||
module.exports = { list, getById, exists }
|
||||
/** Does any OTHER series hold this slug? The uniqueness pre-check. */
|
||||
const slugTaken = async (slug, exceptId = null) => {
|
||||
const rows = exceptId
|
||||
? await query('SELECT id FROM event_series WHERE slug = ? AND id <> ?', [slug, exceptId])
|
||||
: await query('SELECT id FROM event_series WHERE slug = ?', [slug])
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
const insert = async (s) => {
|
||||
const result = await query(
|
||||
`INSERT INTO event_series (name, slug, description, ordering, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[s.name, s.slug, s.description, s.ordering, s.created_by],
|
||||
)
|
||||
return Number(result.insertId)
|
||||
}
|
||||
|
||||
const update = (id, s) =>
|
||||
query(
|
||||
`UPDATE event_series SET name = ?, slug = ?, description = ?, ordering = ? WHERE id = ?`,
|
||||
[s.name, s.slug, s.description, s.ordering, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* A hard delete, and the one place in this feature that is one.
|
||||
*
|
||||
* A series is a label rather than authored content: nothing pins one, no run
|
||||
* references one, and `event_definitions.series_id` is `ON DELETE SET NULL`, so
|
||||
* removing a series detaches its definitions and destroys nothing. That is why
|
||||
* it is not archived the way a definition is — an archived label would be a
|
||||
* state every calendar query has to remember for no benefit. The model answers
|
||||
* with how many definitions were detached, so the operator learns what happened
|
||||
* rather than discovering it on the calendar.
|
||||
*/
|
||||
const remove = (id) => query('DELETE FROM event_series WHERE id = ?', [id])
|
||||
|
||||
module.exports = { list, getById, exists, slugTaken, insert, update, remove }
|
||||
|
||||
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