diff --git a/client/src/App.jsx b/client/src/App.jsx index aef41a0..311d326 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -50,6 +50,7 @@ import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx' import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx' import EngagementRetention from './routes/admin/views/EngagementRetention.jsx' import EventsAdmin from './routes/admin/views/EventsAdmin.jsx' +import EventsCalendar from './routes/admin/views/EventsCalendar.jsx' import EventEditor from './routes/admin/views/EventEditor.jsx' import EventRun from './routes/admin/views/EventRun.jsx' import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx' @@ -204,6 +205,7 @@ export default function App() { route it calls. `runs/:runId` is declared before `:id` so the literal segment is never read as a definition id. */} } /> + } /> } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index a6e0a15..8f979af 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -487,6 +487,24 @@ export const api = { listEventVersions: (id) => req(`/admin/events/${id}/versions`), eventCatalog: () => req('/admin/events/catalog'), eventSeries: () => req('/admin/events/series'), + // Series writes are admin+editor rather than admin: naming an arc is + // authoring, and §N2's narrow gate is about committing the deployment to a + // run. The delete is a real delete and answers with how many definitions it + // detached — `series_id` is ON DELETE SET NULL, so nothing is destroyed. + createEventSeries: (body) => req('/admin/events/series', { method: 'POST', body }), + updateEventSeries: (id, body) => req(`/admin/events/series/${id}`, { method: 'PUT', body }), + deleteEventSeries: (id) => req(`/admin/events/series/${id}`, { method: 'DELETE' }), + // The calendar. `from`/`to` are UTC instants the caller computes from the + // month it is showing, in the READER's zone — the server never guesses it. + // A `status` or `scope` filter suppresses projections, which is why the + // month view sends neither. + eventCalendar: ({ from, to, status, scope, seriesId } = {}) => { + const qs = new URLSearchParams({ from, to }) + if (status) qs.set('status', status) + if (scope) qs.set('scope', scope) + if (seriesId) qs.set('seriesId', String(seriesId)) + return req(`/admin/events/calendar?${qs.toString()}`) + }, startEventRun: (id, body) => req(`/admin/events/${id}/runs`, { method: 'POST', body }), listEventRuns: ({ definitionId, status, limit } = {}) => { const qs = new URLSearchParams() diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js index 8ce3179..77b8073 100644 --- a/client/src/lib/eventAuthoring.js +++ b/client/src/lib/eventAuthoring.js @@ -141,7 +141,7 @@ export function formFromDefinition(event) { concurrencyKey: event?.concurrencyKey || '', graceSeconds: event?.graceSeconds ?? 900, timezone: event?.timezone || 'UTC', - scheduleKind: spec.schedule?.kind || 'manual', + ...scheduleFormFrom(spec.schedule), phases: (spec.phases || []).map((p) => ({ key: p.key || '', label: p.label || '', @@ -204,11 +204,136 @@ export function payloadFromForm(form) { concurrencyKey: form.concurrencyKey || null, graceSeconds: Number(form.graceSeconds), timezone: form.timezone, - spec: { schedule: { kind: form.scheduleKind || 'manual' }, phases }, + spec: { schedule: scheduleFromForm(form), phases }, }, } } + +// ── The schedule (Phase 4) ───────────────────────────────────────────────── +// +// The four closed shapes of §E, mirrored so the form can render one and the +// preview can describe it. `events/spec.js` and `events/recurrence.js` remain +// the deciders — this is what makes the form a form rather than a text box, and +// it is the whole reason the schedule is not a cron string: a closed set has a +// dropdown, and an operator can proofread a dropdown. + +export const WEEKDAYS = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', +] + +export const SCHEDULE_KINDS = [ + { value: 'manual', label: 'Started by hand' }, + { value: 'once', label: 'Once, at a set time' }, + { value: 'weekly', label: 'Weekly, on chosen days' }, + { value: 'monthly', label: 'Monthly, on the nth weekday' }, +] + +// 1..4 and "last". There is no fifth: every month has a first through fourth of +// every weekday, and "last" is what a month with five Fridays makes different +// from "fourth" (org lead, 2026-09-02). +export const MONTHLY_NTHS = [ + { value: 1, label: 'First' }, + { value: 2, label: 'Second' }, + { value: 3, label: 'Third' }, + { value: 4, label: 'Fourth' }, + { value: -1, label: 'Last' }, +] + +const capitalise = (s) => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1) + +/** + * A schedule in words, in the event's own zone. + * + * The server says the same thing in `events/recurrence.js#describe`, and the two + * are allowed to differ on wording but not on meaning — this one is what an + * author reads while they are still typing, before anything has been saved. + */ +export function describeSchedule(schedule, timezone = 'UTC') { + if (!schedule || typeof schedule !== 'object') return 'No schedule' + const nth = MONTHLY_NTHS.find((n) => n.value === Number(schedule.nth)) + switch (schedule.kind) { + case 'manual': + return 'Started by hand — nothing happens until an admin presses Start' + case 'once': { + if (!schedule.at) return 'Once — no date chosen yet' + return `Once, on ${String(schedule.at).replace('T', ' at ')} (${timezone})` + } + case 'weekly': { + const days = (schedule.days || []).map(capitalise) + if (!days.length || !schedule.time) return 'Weekly — choose days and a time' + const list = + days.length === 1 + ? days[0] + : `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}` + return `Every ${list} at ${schedule.time} (${timezone})` + } + case 'monthly': { + if (!nth || !schedule.weekday || !schedule.time) { + return 'Monthly — choose a week, a weekday and a time' + } + return `The ${nth.label.toLowerCase()} ${capitalise(schedule.weekday)} of every month at ${schedule.time} (${timezone})` + } + default: + return 'No schedule' + } +} + +/** + * The schedule half of the editor's working state. + * + * Every shape's fields are kept side by side rather than cleared when the kind + * changes, so an author who clicks Weekly, then Monthly, then back has not lost + * the days they picked. `scheduleFromForm` reads only the fields the chosen kind + * uses, which is what keeps the request body a clean single shape. + */ +export function scheduleFormFrom(schedule) { + const s = schedule || {} + return { + scheduleKind: s.kind || 'manual', + scheduleAt: s.kind === 'once' ? s.at || '' : '', + scheduleDays: s.kind === 'weekly' ? s.days || [] : [], + scheduleNth: s.kind === 'monthly' ? String(s.nth) : '1', + scheduleWeekday: s.kind === 'monthly' ? s.weekday || 'friday' : 'friday', + scheduleTime: s.kind === 'weekly' || s.kind === 'monthly' ? s.time || '20:00' : '20:00', + } +} + +/** The schedule the form describes, as the spec object the server expects. */ +export function scheduleFromForm(form) { + switch (form.scheduleKind) { + case 'once': + return { kind: 'once', at: form.scheduleAt } + case 'weekly': + return { kind: 'weekly', days: form.scheduleDays || [], time: form.scheduleTime } + case 'monthly': + return { + kind: 'monthly', + nth: Number(form.scheduleNth), + weekday: form.scheduleWeekday, + time: form.scheduleTime, + } + default: + return { kind: 'manual' } + } +} + +/** + * What a calendar entry is, and therefore what may be done with it. + * + * A `run` is a row: it has a console and somebody can cancel it. A `projected` + * entry is arithmetic the runner has not reached yet — there is nothing to open + * and nothing to stop, and an operator who treats one as a booking has been + * misled by the UI rather than by the server. + */ +export const isProjected = (entry) => entry?.kind === 'projected' + /** An empty box is `{}`, not a parse error — a step may legitimately take none. */ export function parseParams(text) { const raw = (text || '').trim() diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index d489c5e..db77552 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -133,6 +133,10 @@ export const NAV = [ title: 'Events', items: [ { to: '/admin/events', label: 'Events', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] }, + // Phase 4. The same staff gate as the list beside it: a calendar is a read, + // and the arcs it manages are authoring gated on the buttons rather than + // on the row. + { to: '/admin/events/calendar', label: 'Calendar', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] }, ], }, { @@ -218,6 +222,7 @@ const TITLES = { '/admin/engagement/sends': 'Send Log', '/admin/engagement/retention': 'Retention', '/admin/events': 'Events', + '/admin/events/calendar': 'Event calendar', '/admin/events/new': 'New event', } diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx index 3ab4d99..2e37ff6 100644 --- a/client/src/routes/admin/views/EventEditor.jsx +++ b/client/src/routes/admin/views/EventEditor.jsx @@ -8,6 +8,11 @@ import { payloadFromForm, blankPhase, blankStep, + describeSchedule, + scheduleFromForm, + SCHEDULE_KINDS, + MONTHLY_NTHS, + WEEKDAYS, } from '../../../lib/eventAuthoring.js' // Admin → Events → the definition editor (EVENTS.md §I, Phase 3). @@ -191,7 +196,13 @@ export default function EventEditor() { const result = await api.admin.publishEvent(id) setEvent(result.event) setVersions(await api.admin.listEventVersions(id).then((r) => r.versions || [])) - setNotice(`Published as v${result.version}.`) + // The re-pin count is said out loud, because an editor who does not know + // their fix reached next Friday finds out on Friday. + setNotice( + result.repinned + ? `Published as v${result.version}. ${result.repinned} scheduled occurrence${result.repinned === 1 ? '' : 's'} moved to it.` + : `Published as v${result.version}.`, + ) } catch (err) { setProblems(err.body?.errors || [err.message]) } finally { @@ -329,13 +340,94 @@ export default function EventEditor() { {/* ── Schedule ── */} + {/* + Four closed shapes rendered as a form, never a cron string. A cron + expression is the one field an operator cannot proofread, and the whole + point of the closed set is that this panel can be read back in English — + which is what the preview line under it does. + */}
-

Schedule

-

- Started by hand. Recurrence — once, weekly, monthly on the nth weekday — - is computed in the event’s own timezone and arrives in the next phase, with the calendar. - Until then an occurrence exists because somebody pressed Start now, and the - schedule shape a definition may carry is deliberately the single one the runner honours. +

Schedule

+ +
+ + + {form.scheduleKind === 'once' && ( + + )} + + {form.scheduleKind === 'monthly' && ( + <> + + + + )} + + {(form.scheduleKind === 'weekly' || form.scheduleKind === 'monthly') && ( + + )} +
+ + {form.scheduleKind === 'weekly' && ( +
+ Days +
+ {WEEKDAYS.map((day) => { + const on = (form.scheduleDays || []).includes(day) + return ( + + ) + })} +
+
+ )} + +

+ {describeSchedule(scheduleFromForm(form), form.timezone)} +

+

+ Times are the event’s own, in {form.timezone} — not the reader’s. A + recurring schedule goes live when the definition is published and stops when it is + archived; occurrences become real runs a fortnight before they happen, and the + calendar forecasts the rest. A time that daylight saving skips moves forward to the next + one that exists, and an hour that happens twice takes the first.

diff --git a/client/src/routes/admin/views/EventsAdmin.jsx b/client/src/routes/admin/views/EventsAdmin.jsx index 1153211..8fcc472 100644 --- a/client/src/routes/admin/views/EventsAdmin.jsx +++ b/client/src/routes/admin/views/EventsAdmin.jsx @@ -20,9 +20,10 @@ import { runStatusWord, isTerminalRun } from '../../../lib/eventAuthoring.js' // comes from the run row itself (`waitingSteps`), so a run needs nobody to open // it before it can say so. // -// What is NOT here: a calendar. Recurrence and the month view are Phase 4, and a -// definition today can only carry `schedule: { kind: 'manual' }` — so the honest -// list is a list, and the screen says as much rather than showing an empty grid. +// **The calendar is a separate screen, not a third table here.** It answers +// "when", this one answers "what" — and Phase 4, which built it, also made a +// definition able to carry a recurrence, so the two questions stopped having the +// same answer the moment an occurrence could exist before anybody pressed Start. const STATE_WORD = { draft: 'Draft', ready: 'Ready', archived: 'Archived' } @@ -108,8 +109,9 @@ export default function EventsAdmin() {

Scheduled, bounded, audited changes to the live world. A definition is authored as a draft, published as an immutable version, and every occurrence of it runs against the version it - pinned. Recurrence and the calendar arrive with the next phase — for now an occurrence is - started by hand. + pinned. A definition can repeat — once, weekly, or on the nth weekday of the month, in its + own timezone — and the calendar is where those + occurrences are read.

+ + Calendar + {mayAuthor && ( New event diff --git a/client/src/routes/admin/views/EventsCalendar.jsx b/client/src/routes/admin/views/EventsCalendar.jsx new file mode 100644 index 0000000..a96ec3b --- /dev/null +++ b/client/src/routes/admin/views/EventsCalendar.jsx @@ -0,0 +1,457 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { useAuth } from '../../../contexts/AuthContext.jsx' +import { api } from '../../../api/client.js' +import { runStatusWord, isProjected } from '../../../lib/eventAuthoring.js' + +// Admin → Events → Calendar (EVENTS.md §I, Phase 4). +// +// **This screen is the deliverable.** What this feature replaces is a WordPress +// calendar plugin with no series field, no recurrence and no results — so a +// month grid that knows about arcs, repeats and local time is not decoration +// here, it is the point. +// +// **Two kinds of entry, drawn differently on purpose.** A solid one is a *run*: +// a real row with a status, a pinned version and a console, and somebody can +// cancel it. A dashed one is a *projection*: arithmetic past the runner's +// fourteen-day horizon, with no row behind it, nothing committed and nothing to +// open. An operator who treats a forecast as a booking has been misled by the +// UI, not by the server, so the difference is drawn rather than merely stated — +// and the legend says which is which. +// +// **The grid's date axis is the READER's zone; each entry's time is the +// EVENT's.** §E gives the timezone to the event because every listing this +// replaces is written in the shard's local zone, but "what is happening this +// month" is a question about the month the person reading is living in. So the +// cell an event lands in is the reader's date, and the time beside it always +// carries the event's own zone — `20:00 Europe/Berlin` misreads as nothing. + +const DAY_MS = 86_400_000 + +const STATUS_COLOR = { + failed: '#d98b84', + missed: '#d98b84', + paused: '#d9c184', + cancelled: 'var(--muted)', + running: '#8fc79a', +} + +/** The event's own wall clock, which is the only time worth showing beside it. */ +function localTime(instant, timezone) { + try { + return new Intl.DateTimeFormat(undefined, { + timeZone: timezone, + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).format(new Date(instant)) + } catch { + return new Date(instant).toISOString().slice(11, 16) + } +} + +/** The reader's own date key, which is what places an entry in a cell. */ +const readerDayKey = (instant) => { + const d = new Date(instant) + return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` +} + +/** + * The six-week grid a month view draws, Monday first. + * + * Always six weeks rather than however many the month needs: a grid that + * changes height as you page through it is a grid whose rows move under the + * cursor. + */ +function monthGrid(year, month) { + const first = new Date(year, month, 1) + const offset = (first.getDay() + 6) % 7 + const start = new Date(year, month, 1 - offset) + return Array.from({ length: 42 }, (_, i) => new Date(start.getTime() + i * DAY_MS)) +} + +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +] + +export default function EventsCalendar() { + const { user } = useAuth() + const today = useMemo(() => new Date(), []) + const [year, setYear] = useState(today.getFullYear()) + const [month, setMonth] = useState(today.getMonth()) + const [view, setView] = useState('month') + const [seriesId, setSeriesId] = useState('') + const [series, setSeries] = useState([]) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [managingSeries, setManagingSeries] = useState(false) + + const mayAuthor = user?.role === 'admin' || user?.role === 'editor' + + // The window is the grid's own span, not the month's: an entry in the leading + // or trailing week of the grid belongs to a neighbouring month and still has + // to be fetched, or the first row of every month renders empty. + const grid = useMemo(() => monthGrid(year, month), [year, month]) + const window = useMemo(() => { + if (view === 'month') { + return { from: grid[0], to: new Date(grid[41].getTime() + DAY_MS) } + } + // The list view answers a different question — "what is coming" — so it runs + // forward from now rather than over a calendar month. + const from = new Date() + return { from, to: new Date(from.getTime() + 60 * DAY_MS) } + }, [view, grid]) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const [calendar, seriesList] = await Promise.all([ + api.admin.eventCalendar({ + from: window.from.toISOString(), + to: window.to.toISOString(), + seriesId: seriesId || undefined, + }), + api.admin.eventSeries(), + ]) + setData(calendar) + setSeries(seriesList.series || []) + } catch (err) { + setError(err) + } finally { + setLoading(false) + } + }, [window.from, window.to, seriesId]) + + useEffect(() => { + load() + }, [load]) + + const byDay = useMemo(() => { + const map = new Map() + for (const entry of data?.entries || []) { + const key = readerDayKey(entry.scheduledFor) + if (!map.has(key)) map.set(key, []) + map.get(key).push(entry) + } + return map + }, [data]) + + const step = (delta) => { + const next = new Date(year, month + delta, 1) + setYear(next.getFullYear()) + setMonth(next.getMonth()) + } + + if (loading && !data) return + if (error && !data) return + + const horizon = data?.horizon ? new Date(data.horizon) : null + + return ( + <> +
+
+ {/* + The stepper belongs to the MONTH view only. The list answers "what is + coming" and runs sixty days forward from now whatever month is + selected -- so paging it would be three controls that visibly do + nothing, which is the one thing this feature has refused since Phase + 1. The heading says which question is being asked instead. + */} + {view === 'month' && ( + <> + + + {`${MONTH_NAMES[month]} ${year}`} + + + + + )} + {view === 'list' && ( + The next 60 days + )} +
+ +
+ + + + {mayAuthor && ( + + )} + Events +
+
+ + {/* The legend is not optional. The whole screen rests on the reader + knowing that a dashed entry is not a booking. */} +

+ Scheduled run is a real occurrence with + a console — it can be opened, paused and cancelled.{' '} + Forecast is what the + recurrence works out to beyond the {data?.horizonDays ?? 14}-day horizon: nothing is + committed yet and there is nothing to open. + {horizon && ` Everything up to ${horizon.toLocaleDateString()} is real.`} +

+ + {managingSeries && } + + {data?.truncated && ( +

+ This window has more than the calendar will draw. Narrow it by series, or page to a + shorter span. +

+ )} + + {view === 'month' ? ( +
+
+ {['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((d) => ( +
+ {d} +
+ ))} + {grid.map((day) => { + const entries = byDay.get(readerDayKey(day)) || [] + const outside = day.getMonth() !== month + const isToday = readerDayKey(day) === readerDayKey(today) + return ( +
+
+ {day.getDate()} +
+ {entries.map((entry) => )} +
+ ) + })} +
+
+ ) : ( +
+ {(data?.entries || []).length === 0 ? ( +

+ Nothing is scheduled in the next sixty days.{' '} + {mayAuthor && Author an event} +

+ ) : ( + + + + + + + + + + + {(data?.entries || []).map((entry) => ( + + + + + + + ))} + +
WhenEventSeriesStatus
+ {new Date(entry.scheduledFor).toLocaleDateString()}{' '} + + {localTime(entry.scheduledFor, entry.timezone)} {entry.timezone} + + + {entry.runId ? ( + {entry.title} + ) : ( + {entry.title} + )} + {entry.adjusted === 'gap' && ( + + {' '}(clocks change) + + )} + {entry.seriesName || '—'} + {isProjected(entry) ? Forecast : runStatusWord(entry.status)} + {entry.waitingSteps > 0 && ( + · waiting on a person + )} +
+ )} +
+ )} + + ) +} + +// A projection has no run id, so the definition and the instant are its +// identity — the same pair the server dedupes projections against. +const entryKey = (entry) => + entry.runId ? `run-${entry.runId}` : `proj-${entry.definitionId}-${entry.scheduledFor}` + +const chip = { + display: 'inline-block', + padding: '0 5px', + borderRadius: 3, + borderWidth: 1, + border: '1px solid var(--muted)', + fontSize: '0.72rem', +} + +/** + * The arcs, managed where they are used. + * + * A series is a label, not authored content — nothing pins one and no run + * references one — so this is a small inline panel rather than a screen of its + * own, and it lives on the calendar because the calendar is what makes an arc + * visible in the first place. §I: *"Royal Spy Mission → Risky Partner → Message + * From the Void" is continuity that exists nowhere in the tooling this replaces.* + * + * The delete is a real delete, and it says what it will detach before it + * happens: `series_id` is ON DELETE SET NULL, so the definitions survive without + * an arc and re-attaching one is a dropdown in the editor. Nothing is destroyed, + * which is why this is the one delete in this feature that is not an archive. + */ +function SeriesManager({ series, onChanged }) { + const [name, setName] = useState('') + const [busy, setBusy] = useState(false) + const [problem, setProblem] = useState(null) + + const run = async (fn) => { + setBusy(true) + setProblem(null) + try { + await fn() + await onChanged() + } catch (err) { + setProblem(err?.body?.errors?.join('; ') || err?.message || 'That did not work') + } finally { + setBusy(false) + } + } + + return ( +
+

Series

+

+ An arc several events form together. The order here is where a series sits among the + others; where an event sits within its arc is that event’s own order, in the + editor. +

+ + {problem && ( +

{problem}

+ )} + + {series.map((s) => ( +
+ { + const next = e.target.value.trim() + if (next && next !== s.name) { + run(() => api.admin.updateEventSeries(s.id, { name: next, description: s.description, ordering: s.ordering })) + } + }} /> + { + const next = Number(e.target.value) + if (Number.isInteger(next) && next !== s.ordering) { + run(() => api.admin.updateEventSeries(s.id, { name: s.name, description: s.description, ordering: next })) + } + }} /> + + {s.definitionCount} event{s.definitionCount === 1 ? '' : 's'} + + +
+ ))} + +
+ setName(e.target.value)} /> + +
+
+ ) +} + +function EntryChip({ entry }) { + const projected = isProjected(entry) + const to = entry.runId ? `/admin/events/runs/${entry.runId}` : `/admin/events/${entry.definitionId}` + return ( + + {localTime(entry.scheduledFor, entry.timezone)} {entry.title} + {entry.waitingSteps > 0 && } + + ) +} diff --git a/client/test/eventAuthoring.test.js b/client/test/eventAuthoring.test.js index a1e11c4..1fe8a9b 100644 --- a/client/test/eventAuthoring.test.js +++ b/client/test/eventAuthoring.test.js @@ -12,6 +12,12 @@ import { blankPhase, describeLogLine, runStatusWord, + describeSchedule, + scheduleFormFrom, + scheduleFromForm, + isProjected, + WEEKDAYS, + MONTHLY_NTHS, } from '../src/lib/eventAuthoring.js' // lib/eventAuthoring.js — what the three Events screens say and what they let @@ -308,3 +314,105 @@ test('every run status has a word, and an unknown one falls through rather than } assert.equal(runStatusWord('something-new'), 'something-new') }) + + +// ── The schedule form (Phase 4) ───────────────────────────────────── +// +// The form is the whole argument against cron: a closed set of four shapes has a +// dropdown, and a dropdown can be proofread. What is checked here is that the +// round trip through the form does not quietly change what the author wrote — +// the server would refuse a malformed schedule, but it cannot refuse a +// well-formed one that says something the author did not mean. + +test('a schedule survives the round trip through the form unchanged', () => { + for (const schedule of [ + { kind: 'manual' }, + { kind: 'once', at: '2026-10-31T20:00' }, + { kind: 'weekly', days: ['monday', 'friday'], time: '20:00' }, + { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, + ]) { + const form = scheduleFormFrom(schedule) + assert.deepEqual(scheduleFromForm(form), schedule, JSON.stringify(schedule)) + } +}) + +test('switching kind keeps the other shapes fields, and sends only the chosen one', () => { + // An author who clicks Weekly, then Monthly, then back must not find the days + // they picked gone — but the request body must still be a single clean shape, + // not a union of everything they touched. + const form = { ...scheduleFormFrom({ kind: 'weekly', days: ['friday'], time: '20:00' }), scheduleKind: 'monthly' } + const sent = scheduleFromForm(form) + assert.deepEqual(Object.keys(sent).sort(), ['kind', 'nth', 'time', 'weekday']) + assert.equal(form.scheduleDays.includes('friday'), true) +}) + +test('formFromDefinition carries the whole schedule, not only its kind', () => { + const form = formFromDefinition({ + title: 'Fishing contest', + timezone: 'Europe/Berlin', + spec: { + schedule: { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, + phases: [{ key: 'main', label: 'Main', steps: [] }], + }, + }) + assert.equal(form.scheduleKind, 'monthly') + assert.equal(form.scheduleNth, '-1') + assert.equal(form.scheduleWeekday, 'friday') + assert.equal(form.scheduleTime, '19:30') + + const built = payloadFromForm(form) + assert.equal(built.ok, true) + assert.deepEqual(built.payload.spec.schedule, { + kind: 'monthly', + nth: -1, + weekday: 'friday', + time: '19:30', + }) +}) + +test('a definition with no schedule at all reads as manual rather than as broken', () => { + const form = formFromDefinition({ title: 'x', spec: { phases: [] } }) + assert.equal(form.scheduleKind, 'manual') + assert.deepEqual(scheduleFromForm(form), { kind: 'manual' }) +}) + +test('every schedule describes as a sentence, and a half-built one says what is missing', () => { + assert.match(describeSchedule({ kind: 'manual' }), /by hand/) + assert.equal( + describeSchedule({ kind: 'weekly', days: ['friday', 'saturday'], time: '20:00' }, 'Europe/Berlin'), + 'Every Friday and Saturday at 20:00 (Europe/Berlin)', + ) + assert.equal( + describeSchedule({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, 'Asia/Kolkata'), + 'The last Friday of every month at 19:30 (Asia/Kolkata)', + ) + // Half-built is the state the preview spends most of its life in — an author + // is typing. It must prompt, never render "undefined". + for (const partial of [ + { kind: 'weekly', days: [], time: '20:00' }, + { kind: 'weekly', days: ['friday'], time: '' }, + { kind: 'monthly', nth: 1, weekday: '', time: '19:00' }, + { kind: 'once', at: '' }, + ]) { + const text = describeSchedule(partial, 'UTC') + assert.ok(text.length > 0) + assert.ok(!text.includes('undefined'), `${JSON.stringify(partial)} rendered: ${text}`) + assert.match(text, /choose|no date/i) + } +}) + +test('the weekday and nth vocabularies match the server', () => { + // Verbatim `events/recurrence.js`. A client list that drifted would offer a + // value the server refuses, which is exactly the class of failure this file + // exists to catch. + assert.deepEqual(WEEKDAYS, [ + 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', + ]) + assert.deepEqual(MONTHLY_NTHS.map((n) => n.value), [1, 2, 3, 4, -1]) +}) + +test('a projection is told apart from a run, because only one of them can be acted on', () => { + assert.equal(isProjected({ kind: 'projected', runId: null }), true) + assert.equal(isProjected({ kind: 'run', runId: 12 }), false) + assert.equal(isProjected(null), false) +}) diff --git a/server/routes.guards.json b/server/routes.guards.json index f7e7657..8157a41 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -491,6 +491,15 @@ "requireAuth" ] }, + { + "method": "GET", + "path": "/api/v1/admin/events/calendar", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "GET", "path": "/api/v1/admin/events/catalog", @@ -590,6 +599,33 @@ "requireAuth" ] }, + { + "method": "POST", + "path": "/api/v1/admin/events/series", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/events/series/:seriesId", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/events/series/:seriesId", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "GET", "path": "/api/v1/admin/invites", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 388ae08..63c1d8e 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -217,6 +217,10 @@ "method": "GET", "path": "/api/v1/admin/events/:id/versions" }, + { + "method": "GET", + "path": "/api/v1/admin/events/calendar" + }, { "method": "GET", "path": "/api/v1/admin/events/catalog" @@ -261,6 +265,18 @@ "method": "GET", "path": "/api/v1/admin/events/series" }, + { + "method": "POST", + "path": "/api/v1/admin/events/series" + }, + { + "method": "DELETE", + "path": "/api/v1/admin/events/series/:seriesId" + }, + { + "method": "PUT", + "path": "/api/v1/admin/events/series/:seriesId" + }, { "method": "GET", "path": "/api/v1/admin/invites" diff --git a/server/src/events/recurrence.js b/server/src/events/recurrence.js new file mode 100644 index 0000000..f28439a --- /dev/null +++ b/server/src/events/recurrence.js @@ -0,0 +1,335 @@ +// ── Occurrence arithmetic, in the event's own timezone ───────────────────── +// +// EVENTS.md §E, "Two scheduling decisions the calendar forces", and Phase 4 of +// EVENTS_PLAN.md. Given a closed recurrence shape, an IANA zone and a window, +// this file answers *which UTC instants* an event happens at. Nothing else +// computes an occurrence; the runner materialises what this returns and the +// calendar projects what this returns, so there is exactly one arithmetic to be +// wrong. +// +// **Why there is no library here.** The server's dependency tree has no date +// library at all — no luxon, no date-fns, no tz package (check `package.json` +// before adding one). What it does have is Node's own full tzdata behind +// `Intl.DateTimeFormat`, which is the same database a library would ship a copy +// of and is already what `eventDefinitions.model.js` validates a zone name +// against. So the arithmetic is: *format an instant into the zone's wall clock* +// (which `Intl` does exactly) and invert that mapping by search. Everything +// below is that one idea. +// +// **Why not cron.** Decided in §E and restated in the plan: there is no parser +// in the tree, the only precedent is in the bot (another process), and a cron +// string is the one field an operator cannot proofread. Four closed shapes +// render as a form, and a form is checkable. +// +// **The two DST rules** (org lead, 2026-09-02), which exist because a weekly +// 02:30 event in `Europe/Berlin` is a real thing an operator will author: +// +// - A **nonexistent** local time — the spring-forward gap — steps forward to the +// first wall clock that does exist. 02:30 becomes 03:00, not 03:30: the event +// happens as close to the authored time as the calendar allows. +// - An **ambiguous** local time — the fall-back hour, which happens twice — +// takes the FIRST, the pre-transition offset. +// +// Both are reported back as `adjusted`, so a run can record why its clock reads +// oddly rather than leaving an operator to discover DST for themselves at 3am. +// Neither rule ever drops an occurrence: a weekly event happens every week. + +// Indexed to match `Date#getUTCDay`, which is what the civil-calendar helpers +// below return. Names rather than numbers everywhere an operator can see them — +// `days: ['friday']` is proofreadable and `days: [5]` is not, which is the same +// argument that rejected cron. +const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] + +// `nth: -1` is "the last one in the month", and it is not a synonym for 4: a +// month with five Fridays has a last Friday that is not the fourth. 1..4 always +// exist in every month (1 + 6 + 21 = 28), so there is deliberately no fifth and +// therefore no absent-occurrence case to define (org lead, 2026-09-02). +const MONTHLY_NTH = [1, 2, 3, 4, -1] + +const TIME_RE = /^([01][0-9]|2[0-3]):([0-5][0-9])$/ +const AT_RE = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[T ]([01][0-9]|2[0-3]):([0-5][0-9])$/ + +const MINUTE_MS = 60_000 +const DAY_MS = 86_400_000 + +// No real DST gap exceeds two hours (Lord Howe's is 30 minutes; the largest +// historical jumps are a day, and those are line-of-date changes rather than +// gaps in the local clock). Four hours is a bound, not an expectation: it stops +// a malformed zone turning the search into a hang. +const MAX_GAP_MINUTES = 240 + +// Bounds on what one call may return. A projection window is operator-supplied +// (the calendar's month, the horizon), and an unbounded expansion of a daily +// schedule across a decade is how a calendar request becomes an outage. +const MAX_OCCURRENCES = 500 + +const formatters = new Map() + +function formatterFor(zone) { + let f = formatters.get(zone) + if (!f) { + // `hourCycle: 'h23'` rather than `hour12: false`, which renders midnight as + // hour 24 in some ICU versions and would put every midnight event on the + // previous day. + f = new Intl.DateTimeFormat('en-US', { + timeZone: zone, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + formatters.set(zone, f) + } + return f +} + +/** The wall clock an instant reads as, in this zone. */ +function wallPartsAt(zone, ms) { + const parts = formatterFor(zone).formatToParts(new Date(ms)) + const get = (type) => Number(parts.find((p) => p.type === type)?.value) + return { + y: get('year'), + m: get('month'), + d: get('day'), + h: get('hour'), + mi: get('minute'), + s: get('second'), + } +} + +/** + * That same wall clock as a number, by reading it as though it were UTC. + * + * This is the trick the whole file rests on: two wall clocks are equal exactly + * when these numbers are, and `wallMs - instant` is the zone's offset at that + * instant. It is never a real instant and must not be used as one. + */ +function wallMs(zone, ms) { + const p = wallPartsAt(zone, ms) + return Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s) +} + +const offsetMs = (zone, ms) => wallMs(zone, ms) - ms + +/** + * Every instant that reads as this wall clock in this zone, earliest first. + * + * Ordinarily one. Two in the fall-back hour, none in the spring-forward gap — + * and the length of this array is how the caller tells those three apart. + * + * Sampling the offset a day either side is what makes it correct across a + * transition: subtracting each candidate offset gives the two instants worth + * testing, and the test is whether the instant formats back to what was asked. + */ +function instantsForWall(zone, target) { + const candidates = new Set([ + target - offsetMs(zone, target - DAY_MS), + target - offsetMs(zone, target + DAY_MS), + ]) + const valid = [] + for (const ms of candidates) { + if (wallMs(zone, ms) === target) valid.push(ms) + } + return valid.sort((a, b) => a - b) +} + +/** + * Resolve a local wall clock to a UTC instant, applying the two DST rules. + * + * `{ at, adjusted, shiftMinutes }` — `adjusted` is `null` on an ordinary day, + * `'gap'` when the authored time did not exist and was stepped forward, and + * `'ambiguous'` when it happened twice and the first was taken. + */ +function resolveWall(zone, y, m, d, h, mi) { + const target = Date.UTC(y, m - 1, d, h, mi, 0) + const valid = instantsForWall(zone, target) + if (valid.length === 1) return { at: new Date(valid[0]), adjusted: null, shiftMinutes: 0 } + if (valid.length > 1) return { at: new Date(valid[0]), adjusted: 'ambiguous', shiftMinutes: 0 } + + // The gap. Step the WALL CLOCK forward — not the instant — until it lands on + // a time that exists, which is the first instant after the transition. + for (let step = 1; step <= MAX_GAP_MINUTES; step += 1) { + const shifted = instantsForWall(zone, target + step * MINUTE_MS) + if (shifted.length) return { at: new Date(shifted[0]), adjusted: 'gap', shiftMinutes: step } + } + return null +} + +// ── The civil calendar ───────────────────────────────────────────────────── +// +// Dates with no zone attached: "the 14th of September" as a thing to iterate, +// before any question of what instant it starts at. `Date.UTC` is used purely +// as calendar arithmetic here and none of these numbers is an instant. + +const dayIndex = (y, m, d) => Date.UTC(y, m - 1, d) / DAY_MS + +function civilFromIndex(n) { + const dt = new Date(n * DAY_MS) + return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate() } +} + +const weekdayOf = (y, m, d) => new Date(Date.UTC(y, m - 1, d)).getUTCDay() + +const daysInMonth = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate() + +/** Is this a real date? `2026-02-30` parses as a string and is not a day. */ +const isRealDate = (y, m, d) => m >= 1 && m <= 12 && d >= 1 && d <= daysInMonth(y, m) + +/** + * The day of the month that is the nth (or last) given weekday. + * + * `nth` is 1..4 or -1. Answers `null` only for an nth that cannot exist, which + * the validated shapes never produce — the guard is here so that a spec written + * by hand into the database cannot make the runner throw. + */ +function nthWeekdayDay(y, m, weekday, nth) { + const last = daysInMonth(y, m) + if (nth === -1) { + const back = (weekdayOf(y, m, last) - weekday + 7) % 7 + return last - back + } + const forward = (weekday - weekdayOf(y, m, 1) + 7) % 7 + const day = 1 + forward + (nth - 1) * 7 + return day <= last ? day : null +} + +// ── Expansion ────────────────────────────────────────────────────────────── + +/** + * Every occurrence of `schedule` in `[from, to)`, earliest first. + * + * `[{ at: Date, adjusted, shiftMinutes }]`. `manual` answers `[]` — it is the + * shape that means "there is no recurrence", and an admin's own + * `POST /:id/runs` is the only thing that creates one of its occurrences. + * + * The window is in INSTANTS and the walk is in LOCAL DAYS, which is why each + * walk starts a day early and ends a day late: a local day can begin up to + * fourteen hours either side of the same UTC day. + */ +function occurrencesBetween(schedule, zone, from, to, { limit = MAX_OCCURRENCES } = {}) { + const fromMs = from instanceof Date ? from.getTime() : Number(from) + const toMs = to instanceof Date ? to.getTime() : Number(to) + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) return [] + if (!schedule || typeof schedule !== 'object') return [] + + const cap = Math.min(Math.max(Number(limit) || MAX_OCCURRENCES, 1), MAX_OCCURRENCES) + const out = [] + const keep = (resolved) => { + if (!resolved) return + const t = resolved.at.getTime() + if (t >= fromMs && t < toMs && out.length < cap) out.push(resolved) + } + + if (schedule.kind === 'manual') return [] + + if (schedule.kind === 'once') { + const m = AT_RE.exec(String(schedule.at || '')) + if (!m) return [] + keep(resolveWall(zone, Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]))) + return out + } + + const time = TIME_RE.exec(String(schedule.time || '')) + if (!time) return [] + const hour = Number(time[1]) + const minute = Number(time[2]) + + if (schedule.kind === 'weekly') { + const wanted = new Set( + (schedule.days || []).map((d) => WEEKDAYS.indexOf(String(d))).filter((i) => i >= 0), + ) + if (!wanted.size) return [] + const first = wallPartsAt(zone, fromMs) + const last = wallPartsAt(zone, toMs) + const startDay = dayIndex(first.y, first.m, first.d) - 1 + const endDay = dayIndex(last.y, last.m, last.d) + 1 + for (let n = startDay; n <= endDay && out.length < cap; n += 1) { + const { y, m, d } = civilFromIndex(n) + if (wanted.has(weekdayOf(y, m, d))) keep(resolveWall(zone, y, m, d, hour, minute)) + } + return out + } + + if (schedule.kind === 'monthly') { + const weekday = WEEKDAYS.indexOf(String(schedule.weekday)) + const nth = Number(schedule.nth) + if (weekday < 0 || !MONTHLY_NTH.includes(nth)) return [] + const first = wallPartsAt(zone, fromMs) + const last = wallPartsAt(zone, toMs) + // Months as a single running count, so a window crossing a new year is not + // a special case. + const startMonth = first.y * 12 + (first.m - 1) - 1 + const endMonth = last.y * 12 + (last.m - 1) + 1 + for (let n = startMonth; n <= endMonth && out.length < cap; n += 1) { + const y = Math.floor(n / 12) + const m = (n % 12) + 1 + const day = nthWeekdayDay(y, m, weekday, nth) + if (day) keep(resolveWall(zone, y, m, day, hour, minute)) + } + return out + } + + return [] +} + +/** The next occurrence at or after `from`, or null. A bounded look-ahead. */ +function nextOccurrence(schedule, zone, from, { withinDays = 400 } = {}) { + const fromMs = from instanceof Date ? from.getTime() : Number(from) + const [first] = occurrencesBetween(schedule, zone, fromMs, fromMs + withinDays * DAY_MS, { + limit: 1, + }) + return first || null +} + +/** + * How a schedule reads to a person, in the event's own zone. + * + * Server-side because two surfaces need the same sentence — the calendar's list + * and the run's own record of why it exists — and because the client's copy in + * `eventAuthoring.js` is a mirror that is allowed to drift on wording but not on + * meaning. + */ +function describe(schedule, zone = 'UTC') { + if (!schedule || typeof schedule !== 'object') return 'No schedule' + const cap = (s) => String(s).charAt(0).toUpperCase() + String(s).slice(1) + const nthLabel = { 1: 'first', 2: 'second', 3: 'third', 4: 'fourth', '-1': 'last' } + switch (schedule.kind) { + case 'manual': + return 'Started by hand' + case 'once': + return `Once, on ${String(schedule.at).replace('T', ' ')} (${zone})` + case 'weekly': { + const days = (schedule.days || []).map(cap) + const list = + days.length <= 1 + ? days.join('') + : `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}` + return `Every ${list} at ${schedule.time} (${zone})` + } + case 'monthly': + return `The ${nthLabel[String(schedule.nth)]} ${cap(schedule.weekday)} of every month at ${schedule.time} (${zone})` + default: + return 'No schedule' + } +} + +module.exports = { + WEEKDAYS, + MONTHLY_NTH, + TIME_RE, + AT_RE, + MAX_OCCURRENCES, + DAY_MS, + wallPartsAt, + offsetMs, + instantsForWall, + resolveWall, + isRealDate, + nthWeekdayDay, + occurrencesBetween, + nextOccurrence, + describe, +} diff --git a/server/src/events/spec.js b/server/src/events/spec.js index 7551d96..da888f8 100644 --- a/server/src/events/spec.js +++ b/server/src/events/spec.js @@ -12,18 +12,24 @@ // one that decides. A spec arriving by any other route (a restore, a fixture, a // module shipping a definition as content) gets the same answer. // -// **What Phase 1 knows, and what it deliberately refuses.** Two top-level keys -// exist today: `schedule` and `phases`. `schedule` accepts only `{ kind: -// 'manual' }`, because Phase 4 is what computes an occurrence from a recurrence -// in an IANA zone and a spec that could name `weekly` before then would be a -// schedule nothing honours. Unknown top-level keys are REFUSED rather than -// preserved: a spec that silently carries `announcements` today is a spec whose -// author believes announcements work, and the later phase that gives the key -// meaning would inherit a corpus of unvalidated ones. The refusal list is the -// changelog — Phase 4 adds the recurrence shapes, Phase 5 adds a phase's +// **What this file knows, and what it deliberately refuses.** Two top-level keys +// exist today: `schedule` and `phases`. Unknown top-level keys are REFUSED rather +// than preserved: a spec that silently carries `announcements` today is a spec +// whose author believes announcements work, and the later phase that gives the +// key meaning would inherit a corpus of unvalidated ones. The refusal list is the +// changelog — Phase 4 added the recurrence shapes, Phase 5 adds a phase's // `advance`, Phase 10 adds `announcements`. +// +// **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`, +// `weekly`, `monthly` — and every check on them is a check on SHAPE. The +// arithmetic they describe lives in `events/recurrence.js`, and the zone they are +// computed in is `event_definitions.timezone`, a sibling column this file cannot +// see and does not need to: a well-formed wall clock resolves in every zone (a +// DST gap shifts it, it is never rejected), so a schedule that validates here +// computes there. const registries = require('../modules/registries') +const recurrence = require('./recurrence') const { checkLiteral } = require('../engagement/conditions') // A phase key is a slug: it is stored in `event_run_steps.phase`, it is what the @@ -39,11 +45,20 @@ const MAX_PHASES = 40 const MAX_STEPS_PER_PHASE = 100 const MAX_STEPS = 500 -// The schedule shapes this phase understands. Phase 4 replaces this list with -// the four closed shapes of §E — `once`, `weekly`, `monthly`, `manual` — and -// their timezone arithmetic. It is a list of one rather than an implicit default -// so that the widening is a diff on this line. -const SCHEDULE_KINDS = ['manual'] +// The four closed shapes of §E. `manual` is first because it is the default and +// what an unscheduled draft carries; the other three are recurrences the runner +// expands into occurrences ahead of time. +const SCHEDULE_KINDS = ['manual', 'once', 'weekly', 'monthly'] + +// The keys each shape may carry, and the ONLY ones. A `weekly` that also names +// an `at` is an author who believes something about it that is not true — the +// same argument the top-level refusal makes, one level down. +const SCHEDULE_KEYS = { + manual: [], + once: ['at'], + weekly: ['days', 'time'], + monthly: ['nth', 'weekday', 'time'], +} // What a step does when its attempts are exhausted (§L). The disposition only — // retry is not one of the values, it is what happens BEFORE one of them. Each @@ -63,6 +78,86 @@ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArr /** The default disposition for an action whose risk class core knows. */ const defaultOnFailure = (risk) => ON_FAILURE_BY_RISK[risk] || 'pause' +/** + * Check one schedule shape and answer the normalised form of it. + * + * Always answers a valid schedule — `{ kind: 'manual' }` when the input was not + * one — because `validate` collects every error and carries on, and a caller + * reading `spec.schedule.days` of a refused spec should find an empty recurrence + * rather than a half-built one. + * + * **`days` is normalised into week order**, not into the order they were typed. + * The spec is compared, described and diffed, and `['friday','monday']` and + * `['monday','friday']` naming the same schedule while differing as JSON is a + * version history that reports edits nobody made. + */ +function validateSchedule(kind, raw, errors) { + const at = (key) => `spec.schedule.${key}` + + if (kind === 'once') { + const m = recurrence.AT_RE.exec(String(raw.at ?? '')) + if (!m) { + errors.push(`${at('at')}: expected a local date and time as YYYY-MM-DDTHH:MM`) + return { kind: 'manual' } + } + const [, y, mo, d, h, mi] = m.map(Number) + // The regex admits `2026-02-30`, which is a string and not a day. + if (!recurrence.isRealDate(y, mo, d)) { + errors.push(`${at('at')}: "${raw.at}" is not a real date`) + return { kind: 'manual' } + } + // Stored as the operator wrote it — a wall clock in the definition's own + // zone, never a UTC instant. §E: the schedule belongs to the event, and the + // instant is derived at materialisation. + const pad = (n) => String(n).padStart(2, '0') + return { kind: 'once', at: `${y}-${pad(mo)}-${pad(d)}T${pad(h)}:${pad(mi)}` } + } + + if (kind === 'weekly' || kind === 'monthly') { + const time = recurrence.TIME_RE.test(String(raw.time ?? '')) ? String(raw.time) : null + if (!time) errors.push(`${at('time')}: expected a 24-hour time as HH:MM`) + + if (kind === 'weekly') { + const rawDays = Array.isArray(raw.days) ? raw.days : null + if (!rawDays || rawDays.length === 0) { + errors.push(`${at('days')}: expected a non-empty array of weekday names`) + return { kind: 'manual' } + } + const unknown = rawDays.filter((d) => !recurrence.WEEKDAYS.includes(String(d).toLowerCase())) + if (unknown.length) { + errors.push( + `${at('days')}: unknown weekday(s) ${unknown.join(', ')} — expected ${recurrence.WEEKDAYS.join(', ')}`, + ) + } + const days = recurrence.WEEKDAYS.filter((name) => + rawDays.some((d) => String(d).toLowerCase() === name), + ) + if (!time || !days.length) return { kind: 'manual' } + return { kind: 'weekly', days, time } + } + + const weekday = String(raw.weekday ?? '').toLowerCase() + if (!recurrence.WEEKDAYS.includes(weekday)) { + errors.push( + `${at('weekday')}: expected one of ${recurrence.WEEKDAYS.join(', ')}`, + ) + } + const nth = Number(raw.nth) + if (!recurrence.MONTHLY_NTH.includes(nth)) { + // -1 is "last", which a month with five Fridays makes different from 4. + // There is no 5: every month has a first through fourth of every weekday, + // so the closed set has no absent case (org lead, 2026-09-02). + errors.push(`${at('nth')}: expected 1, 2, 3, 4 or -1 (last)`) + } + if (!time || !recurrence.WEEKDAYS.includes(weekday) || !recurrence.MONTHLY_NTH.includes(nth)) { + return { kind: 'manual' } + } + return { kind: 'monthly', nth, weekday, time } + } + + return { kind: 'manual' } +} + /** * Check one authored param object against an action's declared params. * @@ -136,18 +231,21 @@ function validate(raw, { knownActionIds = [] } = {}) { } // ── schedule ── - const rawSchedule = raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule + const rawSchedule = + raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule let schedule = { kind: 'manual' } if (!isPlainObject(rawSchedule)) { errors.push('spec.schedule: expected an object') } else if (!SCHEDULE_KINDS.includes(rawSchedule.kind)) { - errors.push( - `spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')} (recurrence arrives in Phase 4)`, - ) + errors.push(`spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')}`) } else { - const extra = Object.keys(rawSchedule).filter((k) => k !== 'kind') - if (extra.length) errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')}`) - schedule = { kind: rawSchedule.kind } + const kind = rawSchedule.kind + const allowedKeys = new Set(['kind', ...SCHEDULE_KEYS[kind]]) + const extra = Object.keys(rawSchedule).filter((k) => !allowedKeys.has(k)) + if (extra.length) { + errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')} for kind "${kind}"`) + } + schedule = validateSchedule(kind, rawSchedule, errors) } // ── phases ── diff --git a/server/src/model/events/eventCalendar.model.js b/server/src/model/events/eventCalendar.model.js new file mode 100644 index 0000000..3dedc85 --- /dev/null +++ b/server/src/model/events/eventCalendar.model.js @@ -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 } diff --git a/server/src/model/events/eventDefinitions.db.js b/server/src/model/events/eventDefinitions.db.js index 0794cfe..c237e79 100644 --- a/server/src/model/events/eventDefinitions.db.js +++ b/server/src/model/events/eventDefinitions.db.js @@ -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, diff --git a/server/src/model/events/eventDefinitions.model.js b/server/src/model/events/eventDefinitions.model.js index be5008f..813c2ce 100644 --- a/server/src/model/events/eventDefinitions.model.js +++ b/server/src/model/events/eventDefinitions.model.js @@ -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) } } /** diff --git a/server/src/model/events/eventRuns.db.js b/server/src/model/events/eventRuns.db.js index 3c976f3..e537435 100644 --- a/server/src/model/events/eventRuns.db.js +++ b/server/src/model/events/eventRuns.db.js @@ -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, diff --git a/server/src/model/events/eventRuns.model.js b/server/src/model/events/eventRuns.model.js index 34294c5..f1dbd20 100644 --- a/server/src/model/events/eventRuns.model.js +++ b/server/src/model/events/eventRuns.model.js @@ -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, }, }) diff --git a/server/src/model/events/eventSeries.db.js b/server/src/model/events/eventSeries.db.js index 2ed3729..17a6658 100644 --- a/server/src/model/events/eventSeries.db.js +++ b/server/src/model/events/eventSeries.db.js @@ -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 } diff --git a/server/src/model/events/eventSeries.model.js b/server/src/model/events/eventSeries.model.js new file mode 100644 index 0000000..04eb1e1 --- /dev/null +++ b/server/src/model/events/eventSeries.model.js @@ -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 } diff --git a/server/src/router/v1/admin/events.controller.js b/server/src/router/v1/admin/events.controller.js index a9b0900..a070cbe 100644 --- a/server/src/router/v1/admin/events.controller.js +++ b/server/src/router/v1/admin/events.controller.js @@ -23,6 +23,9 @@ const definitionsDb = require('../../../model/events/eventDefinitions.db') const definitions = require('../../../model/events/eventDefinitions.model') const versionsDb = require('../../../model/events/eventVersions.db') const seriesDb = require('../../../model/events/eventSeries.db') +const series = require('../../../model/events/eventSeries.model') +const calendarModel = require('../../../model/events/eventCalendar.model') +const eventRunner = require('../../../utils/eventRunner') const runsDb = require('../../../model/events/eventRuns.db') const runs = require('../../../model/events/eventRuns.model') const controls = require('../../../model/events/eventRunControls.model') @@ -152,17 +155,83 @@ exports.catalog = (_req, res) => { }) } +const shapeSeries = (s) => ({ + id: s.id, + name: s.name, + slug: s.slug, + description: s.description, + ordering: s.ordering, + definitionCount: Number(s.definition_count || 0), +}) + /** GET /api/v1/admin/events/series */ exports.listSeries = async (_req, res) => { const rows = await seriesDb.list() + res.json({ series: rows.map(shapeSeries) }) +} + +/** POST /api/v1/admin/events/series */ +exports.createSeries = async (req, res) => { + const result = await series.create(req.body, req.user?.id) + if (!result.ok) return res.status(result.status).json({ errors: result.errors }) + await activity.log({ + req, + action: 'event.series.created', + detail: { id: result.series.id, name: result.series.name }, + }) + res.status(201).json({ series: shapeSeries(result.series) }) +} + +/** PUT /api/v1/admin/events/series/:seriesId */ +exports.updateSeries = async (req, res) => { + const id = asId(req.params.seriesId) + if (!id) return res.status(404).json({ error: 'no such series' }) + const result = await series.update(id, req.body, req.user?.id) + if (!result.ok) return res.status(result.status).json({ errors: result.errors }) + await activity.log({ req, action: 'event.series.updated', detail: { id, name: result.series.name } }) + res.json({ series: shapeSeries(result.series) }) +} + +/** + * DELETE /api/v1/admin/events/series/:seriesId + * + * `detached` is in the response because the delete is not confined to the row: + * `series_id` is `ON DELETE SET NULL`, so definitions that belonged to the arc + * survive it without one. Saying how many is the difference between an operator + * knowing and an operator finding out. + */ +exports.deleteSeries = async (req, res) => { + const id = asId(req.params.seriesId) + if (!id) return res.status(404).json({ error: 'no such series' }) + const result = await series.remove(id) + if (!result.ok) return res.status(result.status).json({ errors: result.errors }) + await activity.log({ req, action: 'event.series.deleted', detail: { id, detached: result.detached } }) + res.json({ ok: true, detached: result.detached }) +} + +/** + * GET /api/v1/admin/events/calendar + * + * `from` and `to` are UTC instants and the caller supplies both: a month grid + * knows its own boundaries in the viewer's zone, and having the server guess + * them would be the server guessing the viewer's zone. + */ +exports.calendar = async (req, res) => { + const result = await calendarModel.calendar({ + from: req.query.from, + to: req.query.to, + status: req.query.status || null, + scope: req.query.scope || null, + seriesId: asId(req.query.seriesId), + horizonDays: eventRunner.HORIZON_DAYS, + }) + if (!result.ok) return res.status(result.status).json({ errors: result.errors }) res.json({ - series: rows.map((s) => ({ - id: s.id, - name: s.name, - slug: s.slug, - description: s.description, - ordering: s.ordering, - })), + window: result.window, + horizon: result.horizon, + horizonDays: eventRunner.HORIZON_DAYS, + entries: result.entries, + truncated: result.truncated, }) } @@ -272,12 +341,16 @@ exports.publish = async (req, res) => { await activity.log({ req, action: 'event.definition.published', - detail: { id, version: result.version, versionId: result.versionId }, + detail: { id, version: result.version, versionId: result.versionId, repinned: result.repinned }, }) return res.json({ event: shapeDefinition(result.definition), version: result.version, versionId: result.versionId, + // How many already-materialised occurrences moved to this version. The + // screen says so, because "my fix did not reach next Friday" is otherwise + // found out on Friday. + repinned: result.repinned, }) } diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js index 9c1360b..980cf62 100644 --- a/server/src/router/v1/admin/events.router.js +++ b/server/src/router/v1/admin/events.router.js @@ -18,8 +18,13 @@ // stubbed — there is no advance condition until Phase 5, no resource ledger // until Phase 8 and no caps to price against until Phase 6. // -// **Literal paths are declared before `/:id`**, so `/catalog`, `/series` and -// `/runs` are never read as an event id. +// **Literal paths are declared before `/:id`**, so `/catalog`, `/series`, +// `/calendar` and `/runs` are never read as an event id. +// +// **Phase 4 added the series writes and the calendar.** The series writes are +// `admin, editor` rather than `admin`: naming an arc is authoring, and §N2's +// narrow gate is about committing the deployment to a run. The calendar is a +// staff read like every other read here. const express = require('express') @@ -51,13 +56,74 @@ eventsRouter.get( '/series', // #swagger.tags = ['Admin · Events'] // #swagger.summary = 'List the event series a definition may belong to' - // #swagger.description = 'A series is the arc several definitions form together. Read-only in this phase: creating and ordering one arrives with the calendar.' + // #swagger.description = 'A series is the arc several definitions form together - Royal Spy Mission then Risky Partner then Message From the Void - which is continuity the tooling this feature replaces has no field for at all. definitionCount is how many definitions currently belong to each.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'The series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "array", items: { type: "object", properties: { id: { type: "integer" }, name: { type: "string" }, slug: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } } } } } } } } } */ /* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ controller.listSeries, ) +eventsRouter.post( + '/series', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'Create an event series' + // #swagger.description = 'Admin or editor, not admin alone: naming an arc is authoring, and the narrow gate of section N2 is about committing the deployment to a run (publish, start), which this does not. The slug is derived from the name once and then frozen, because the public arc page lives at it; renaming the series afterwards is free. ordering places this series among the others on the calendar, and is not a position within it - a definition place in its arc is its own seriesOrder.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } }, required: ["name"] } } } } */ + /* #swagger.responses[201] = { description: 'The created series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[400] = { description: 'Validation failed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */ + /* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOrEditor, + controller.createSeries, +) + +eventsRouter.put( + '/series/:seriesId', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'Rename or reorder an event series' + // #swagger.description = 'The slug is deliberately not editable: it is the address the arc page lives at, and a slug that moved would break every link to it.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } }, required: ["name"] } } } } */ + /* #swagger.responses[200] = { description: 'The updated series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[400] = { description: 'Validation failed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */ + /* #swagger.responses[404] = { description: 'No such series', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOrEditor, + controller.updateSeries, +) + +eventsRouter.delete( + '/series/:seriesId', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'Delete an event series, detaching whatever belonged to it' + // #swagger.description = 'A hard delete, and the only one in this feature - a definition is archived instead. 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 its definitions survive without an arc and re-attaching is a dropdown. The response says how many were detached.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Deleted; detached is how many definitions lost their series', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, detached: { type: "integer" } } } } } } */ + /* #swagger.responses[404] = { description: 'No such series', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOrEditor, + controller.deleteSeries, +) + +// ── The calendar ──────────────────────────────────────────────────── + +eventsRouter.get( + '/calendar', + // #swagger.tags = ['Admin · Events'] + // #swagger.summary = 'The calendar for a window: materialised runs and projected occurrences' + // #swagger.description = 'Staff, like every other read here. Each entry is one of two kinds and the difference matters: a run entry is a real row with a status, a pinned version and a console, and somebody can cancel it; a projected entry is arithmetic - no row, nothing committed, nothing to cancel. Runs exist inside the runner materialisation horizon (14 days by default, horizonDays in the response); beyond it the same recurrence arithmetic forecasts what will be materialised, so a monthly event is still visible three weeks out. A projection is never emitted for an instant a run already occupies, which is also why a cancelled occurrence does not reappear as a forecast. Instants are UTC and each entry carries the event own IANA zone: the event owns the time, the reader owns the calendar. Filtering by status or by a named scope suppresses projections, because a forecast has no status and automatic expansion happens at the empty scope.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['from'] = { in: 'query', description: 'Window start, a UTC instant', required: true, schema: { type: 'string' } } + // #swagger.parameters['to'] = { in: 'query', description: 'Window end, a UTC instant. At most 92 days after from', required: true, schema: { type: 'string' } } + // #swagger.parameters['status'] = { in: 'query', description: 'Only runs in this status; suppresses projections', required: false, schema: { type: 'string' } } + // #swagger.parameters['scope'] = { in: 'query', description: 'Only runs at this scope; suppresses projections', required: false, schema: { type: 'string' } } + // #swagger.parameters['seriesId'] = { in: 'query', description: 'Only events belonging to this series', required: false, schema: { type: 'integer' } } + /* #swagger.responses[200] = { description: 'The window', content: { "application/json": { schema: { type: "object", properties: { window: { type: "object", additionalProperties: true }, horizon: { type: "string" }, horizonDays: { type: "integer" }, truncated: { type: "boolean" }, entries: { type: "array", items: { type: "object", properties: { kind: { type: "string" }, runId: { type: "integer", nullable: true }, definitionId: { type: "integer" }, title: { type: "string" }, slug: { type: "string" }, seriesName: { type: "string", nullable: true }, scheduledFor: { type: "string" }, timezone: { type: "string" }, scope: { type: "string" }, status: { type: "string", nullable: true }, health: { type: "string", nullable: true }, adjusted: { type: "string", nullable: true } } } } } } } } } */ + /* #swagger.responses[400] = { description: 'The window is missing, inverted or wider than 92 days', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */ + /* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + controller.calendar, +) + // ── Runs ────────────────────────────────────────────────────────────────── // // Declared ahead of /:id so the literal path is never read as a definition id. @@ -265,9 +331,9 @@ eventsRouter.post( '/:id/publish', // #swagger.tags = ['Admin · Events'] // #swagger.summary = 'Snapshot the working spec into an immutable version and mark the definition ready' - // #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch.' + // #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'The definition, now ready, and the version that was cut', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" } } } } } } */ + /* #swagger.responses[200] = { description: 'The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" }, repinned: { type: "integer" } } } } } } */ /* #swagger.responses[400] = { description: 'The spec is invalid, or no phase has any steps', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */ /* #swagger.responses[409] = { description: 'A step names an action no module registers, or the definition is archived', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */ /* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ diff --git a/server/src/utils/eventRunner.js b/server/src/utils/eventRunner.js index 3c7bee7..9f6b4c1 100644 --- a/server/src/utils/eventRunner.js +++ b/server/src/utils/eventRunner.js @@ -12,14 +12,22 @@ // 3. **advance** — claim each due run and move it through its phases // 4. **prune** — the `event_run_log` retention sweep, on its own long clock // -// **What "materialise" means in this phase.** §E's tick materialises due -// occurrences from a recurrence; the spec validator accepts `kind: 'manual'` -// alone until Phase 4, so there is no recurrence to expand and the only -// occurrences that exist are the ones an admin created. What that leaves for this -// leg is the half that is already real and already needed: the grace window. A -// run whose instant passed while the process was down does not start late and -// silently — it becomes `missed`, which is terminal and which a human can see -// (§L). Phase 4 adds the expansion above it. +// **What "materialise" means, and why it is two halves.** Phase 4 completed it. +// The first half EXPANDS: every `ready` definition's recurrence is computed in +// its own IANA zone and every occurrence inside a fourteen-day horizon becomes a +// real `scheduled` row (`INSERT IGNORE` against the occurrence key, so the tick +// that already made one makes nothing). The second half SWEEPS: a run whose +// instant passed while the process was down does not start late and silently — +// it becomes `missed`, which is terminal and which a human can see (§L). +// +// **The two halves need each other, and the horizon is why.** Expansion looks +// forward from `now - grace` only, so an occurrence nobody ever materialised is +// never invented retroactively — waking up after three days down must not +// manufacture three days of history that no operator could have seen or +// cancelled. It does not have to: because rows exist a fortnight ahead of their +// instant, an outage that spans an occurrence finds the row already there, and +// the sweep marks it `missed` honestly. The horizon is what makes the missed +// sweep mean anything for a recurring event. // // **Two properties this file must not lose**, both already paid for once on this // codebase: @@ -45,6 +53,9 @@ const runsDb = require('../model/events/eventRuns.db') const stepsDb = require('../model/events/eventRunSteps.db') const logDb = require('../model/events/eventRunLog.db') const versionsDb = require('../model/events/eventVersions.db') +const definitionsDb = require('../model/events/eventDefinitions.db') +const runsModel = require('../model/events/eventRuns.model') +const recurrence = require('../events/recurrence') const registries = require('../modules/registries') const { dispatchStep } = require('../events/dispatch') const log = require('./logger')('event-runner') @@ -59,6 +70,19 @@ const POLL_MS = Number(process.env.EVENT_POLL_MS) || 15_000 const RUN_BATCH = Number(process.env.EVENT_RUN_BATCH) || 50 const STEPS_PER_TICK = Number(process.env.EVENT_STEPS_PER_TICK) || 25 +// How far ahead a recurrence is turned into real rows (org lead, 2026-09-02). +// Fourteen days is a fortnight of occurrences an operator can see, cancel and +// reschedule ONE AT A TIME, which a projection is not — and it is short enough +// that a definition edited today affects almost everything still ahead of it. +// Beyond it the calendar projects rather than materialises, so a monthly event +// is still visible three weeks out without a row nobody will honour. +const HORIZON_DAYS = Number(process.env.EVENT_MATERIALISE_AHEAD_DAYS) || 14 + +// A bound on one definition's expansion in one tick, not a target. A daily +// schedule over a fortnight is fourteen; this is what stops a hand-written spec +// turning one tick into a thousand inserts. +const MAX_OCCURRENCES_PER_DEFINITION = 100 + // A step's retries (org lead, 2026-09-02). §L specifies `retry(n) -> skip | // pause | abort_run` and names no `n`; it lives here, as one number an operator // can change, rather than in a column no authoring surface would ever show. @@ -397,6 +421,98 @@ async function processRun(run, now = new Date()) { } /** Occurrences that passed their own grace window while nothing was running (§L). */ +/** + * Turn every `ready` definition's recurrence into rows inside the horizon. + * + * The first half of the materialise leg (§E). Answers how many occurrences were + * newly created, which is zero on almost every tick — the horizon moves fifteen + * seconds at a time, so a weekly event creates one row a week and answers + * `created: false` for the same fourteen occurrences in between. + * + * **The window starts at `now - grace`, not at `now`.** An occurrence whose + * instant just passed is still startable inside the definition's own grace + * window, and that is exactly the case of a definition published four minutes + * before its first occurrence. An occurrence older than that is not materialised + * at all rather than materialised-then-swept: a row nobody could ever have seen + * is not history, and writing one would put a `missed` event on the calendar for + * a date on which this deployment had no such event. + * + * **Expansion is per definition and one failure does not stop the sweep.** A + * spec written directly into the database with a shape the validator would have + * refused is a bad row, not a bad tick. + */ +async function expandSchedules(now) { + const definitions = await definitionsDb.findSchedulable() + let created = 0 + + for (const definition of definitions) { + const schedule = definition.version_spec?.schedule + if (!schedule || schedule.kind === 'manual') continue + + const from = now.getTime() - Number(definition.grace_seconds || 0) * 1000 + const to = now.getTime() + HORIZON_DAYS * recurrence.DAY_MS + + let occurrences + try { + occurrences = recurrence.occurrencesBetween(schedule, definition.timezone || 'UTC', from, to, { + limit: MAX_OCCURRENCES_PER_DEFINITION, + }) + } catch (err) { + log.error('could not expand a schedule', { + definition: definition.id, + timezone: definition.timezone, + message: err.message, + }) + continue + } + + for (const occurrence of occurrences) { + try { + // `runsModel.create` rather than a second insert path: it re-checks that + // the definition is still `ready` and the version still has phases, and + // it materialises the first phase's steps with their idempotency keys. + // Nobody is watching a scheduled occurrence, so it needs those checks + // more than a hand-started one does. + const result = await runsModel.create( + definition.id, + // Scope is empty, deliberately (org lead, 2026-09-02). A fan-out across + // named scopes needs a registry of what a scope IS, which no phase owns + // yet; inventing one here would be a contract the modules were never + // asked about. An admin's own start route still takes any scope. + { scope: '', scheduledFor: occurrence.at, source: 'schedule' }, + null, + ) + if (!result.ok || !result.created) continue + created += 1 + if (occurrence.adjusted) { + // Why the clock reads oddly, recorded where an operator will look for + // it rather than left to be rediscovered at 3am on the last Sunday in + // October. + await logDb.write({ + runId: result.run.id, + kind: 'run.created', + detail: { + dstAdjusted: occurrence.adjusted, + shiftMinutes: occurrence.shiftMinutes, + timezone: definition.timezone, + scheduledFor: occurrence.at, + }, + }) + } + } catch (err) { + log.error('could not materialise an occurrence', { + definition: definition.id, + at: occurrence.at, + message: err.message, + }) + } + } + } + + if (created) log.info('occurrences materialised', { created, horizonDays: HORIZON_DAYS }) + return created +} + async function sweepMissed(now) { const missed = await runsDb.findMissed(now) let n = 0 @@ -433,6 +549,12 @@ async function tick(now = new Date()) { log.error('failed to reclaim stale claims', { message: err.message }) } + try { + await expandSchedules(now) + } catch (err) { + log.error('schedule expansion failed', { message: err.message }) + } + try { await sweepMissed(now) } catch (err) { @@ -507,11 +629,13 @@ module.exports = { advanceRun, drainStep, sweepMissed, + expandSchedules, prune, OWNER, POLL_MS, MAX_ATTEMPTS, RETRY_MS, + HORIZON_DAYS, RUN_LEASE_MS, LOG_RETENTION_DAYS, RUN_TERMINAL, diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 8d7a039..10ba6ba 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -3534,6 +3534,174 @@ } } }, + "/api/v1/admin/events/calendar": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "The calendar for a window: materialised runs and projected occurrences", + "description": "Staff, like every other read here. Each entry is one of two kinds and the difference matters: a run entry is a real row with a status, a pinned version and a console, and somebody can cancel it; a projected entry is arithmetic - no row, nothing committed, nothing to cancel. Runs exist inside the runner materialisation horizon (14 days by default, horizonDays in the response); beyond it the same recurrence arithmetic forecasts what will be materialised, so a monthly event is still visible three weeks out. A projection is never emitted for an instant a run already occupies, which is also why a cancelled occurrence does not reappear as a forecast. Instants are UTC and each entry carries the event own IANA zone: the event owns the time, the reader owns the calendar. Filtering by status or by a named scope suppresses projections, because a forecast has no status and automatic expansion happens at the empty scope.", + "parameters": [ + { + "name": "from", + "in": "query", + "description": "Window start, a UTC instant", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "description": "Window end, a UTC instant. At most 92 days after from", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "description": "Only runs in this status; suppresses projections", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "scope", + "in": "query", + "description": "Only runs at this scope; suppresses projections", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "seriesId", + "in": "query", + "description": "Only events belonging to this series", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "The window", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "window": { + "type": "object", + "additionalProperties": true + }, + "horizon": { + "type": "string" + }, + "horizonDays": { + "type": "integer" + }, + "truncated": { + "type": "boolean" + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "runId": { + "type": "integer", + "nullable": true + }, + "definitionId": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "seriesName": { + "type": "string", + "nullable": true + }, + "scheduledFor": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "status": { + "type": "string", + "nullable": true + }, + "health": { + "type": "string", + "nullable": true + }, + "adjusted": { + "type": "string", + "nullable": true + } + } + } + } + } + } + } + } + }, + "400": { + "description": "The window is missing, inverted or wider than 92 days", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "403": { + "description": "Not staff", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/events/catalog": { "get": { "tags": [ @@ -4431,7 +4599,7 @@ "Admin · Events" ], "summary": "List the event series a definition may belong to", - "description": "A series is the arc several definitions form together. Read-only in this phase: creating and ordering one arrives with the calendar.", + "description": "A series is the arc several definitions form together - Royal Spy Mission then Risky Partner then Message From the Void - which is continuity the tooling this feature replaces has no field for at all. definitionCount is how many definitions currently belong to each.", "responses": { "200": { "description": "The series", @@ -4488,6 +4656,265 @@ "bearerAuth": [] } ] + }, + "post": { + "tags": [ + "Admin · Events" + ], + "summary": "Create an event series", + "description": "Admin or editor, not admin alone: naming an arc is authoring, and the narrow gate of section N2 is about committing the deployment to a run (publish, start), which this does not. The slug is derived from the name once and then frozen, because the public arc page lives at it; renaming the series afterwards is free. ordering places this series among the others on the calendar, and is not a position within it - a definition place in its arc is its own seriesOrder.", + "responses": { + "201": { + "description": "The created series", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "series": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "Validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "403": { + "description": "Not an admin or editor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "ordering": { + "type": "integer" + } + }, + "required": [ + "name" + ] + } + } + } + } + } + }, + "/api/v1/admin/events/series/{seriesId}": { + "put": { + "tags": [ + "Admin · Events" + ], + "summary": "Rename or reorder an event series", + "description": "The slug is deliberately not editable: it is the address the arc page lives at, and a slug that moved would break every link to it.", + "parameters": [ + { + "name": "seriesId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The updated series", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "series": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "Validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "403": { + "description": "Not an admin or editor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No such series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "ordering": { + "type": "integer" + } + }, + "required": [ + "name" + ] + } + } + } + } + }, + "delete": { + "tags": [ + "Admin · Events" + ], + "summary": "Delete an event series, detaching whatever belonged to it", + "description": "A hard delete, and the only one in this feature - a definition is archived instead. 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 its definitions survive without an arc and re-attaching is a dropdown. The response says how many were detached.", + "parameters": [ + { + "name": "seriesId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Deleted; detached is how many definitions lost their series", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "detached": { + "type": "integer" + } + } + } + } + } + }, + "403": { + "description": "Not an admin or editor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No such series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] } }, "/api/v1/admin/events/{id}": { @@ -4755,7 +5182,7 @@ "Admin · Events" ], "summary": "Snapshot the working spec into an immutable version and mark the definition ready", - "description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch.", + "description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.", "parameters": [ { "name": "id", @@ -4768,7 +5195,7 @@ ], "responses": { "200": { - "description": "The definition, now ready, and the version that was cut", + "description": "The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it", "content": { "application/json": { "schema": { @@ -4783,6 +5210,9 @@ }, "versionId": { "type": "integer" + }, + "repinned": { + "type": "integer" } } } diff --git a/server/test/eventRecurrence.test.js b/server/test/eventRecurrence.test.js new file mode 100644 index 0000000..9c88b13 --- /dev/null +++ b/server/test/eventRecurrence.test.js @@ -0,0 +1,273 @@ +// ── Occurrence arithmetic (EVENTS.md §E, Phase 4) ────────────────────────── +// +// The plan asks for DST-crossing cases as EXPLICIT FIXTURES, and this file is +// them. The reason it is worth a test file of its own is that every failure here +// is silent in production: an event computed an hour off, or dropped for one +// week a year, looks exactly like an event that happened correctly until an +// operator is standing in the wrong place at the wrong time. +// +// The zone data is Node's own tzdata behind `Intl`, so these fixtures assert +// against the real transitions rather than against a hand-written offset table: +// +// • Europe/Berlin, 2026-03-29 — CET (+1) to CEST (+2). 02:00 to 03:00 does not +// exist. A weekly 02:30 event is the case the org lead decided. +// • Europe/Berlin, 2026-10-25 — CEST (+2) back to CET (+1). 02:00 to 03:00 +// happens twice. +// • Asia/Kolkata — +05:30, no DST at all, and a half-hour offset, which is +// what catches an implementation that assumed whole hours. +// • Australia/Lord_Howe — a THIRTY-MINUTE DST shift, which is what catches one +// that assumed the gap is always an hour. + +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after } = require('node:test') +const assert = require('node:assert/strict') + +const r = require('../src/events/recurrence') +const db = require('../src/utils/db') + +after(() => db.close()) + +/** What an instant reads as on the wall in a zone — the assertion that matters. */ +const wall = (zone, at) => { + const p = r.wallPartsAt(zone, at instanceof Date ? at.getTime() : at) + const pad = (n) => String(n).padStart(2, '0') + return `${p.y}-${pad(p.m)}-${pad(p.d)} ${pad(p.h)}:${pad(p.mi)}` +} + +const walls = (occurrences, zone) => occurrences.map((o) => wall(zone, o.at)) + +// ── The rule the whole feature rests on ──────────────────────────────────── + +test('a weekly event keeps its LOCAL time across a DST boundary', () => { + // The single most important assertion in this file. Friday 20:00 in Berlin is + // 19:00 UTC in winter and 18:00 UTC in summer, and it is 20:00 on the wall on + // every one of those Fridays. A recurrence computed in UTC would put half the + // year an hour out, which is exactly what §E forbids. + const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' } + const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 11)) + + assert.deepEqual(walls(found, 'Europe/Berlin'), [ + '2026-03-20 20:00', + '2026-03-27 20:00', + '2026-04-03 20:00', + '2026-04-10 20:00', + ]) + // And the UTC instants really did move, which is what proves the zone was + // consulted rather than the arithmetic accidentally agreeing. + assert.equal(found[1].at.toISOString(), '2026-03-27T19:00:00.000Z') + assert.equal(found[2].at.toISOString(), '2026-04-03T18:00:00.000Z') +}) + +test('a weekly event keeps its local time across the October transition too', () => { + const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' } + const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 9, 20), Date.UTC(2026, 10, 7)) + assert.deepEqual(walls(found, 'Europe/Berlin'), [ + '2026-10-23 20:00', + '2026-10-30 20:00', + '2026-11-06 20:00', + ]) + assert.equal(found[0].at.toISOString(), '2026-10-23T18:00:00.000Z') + assert.equal(found[1].at.toISOString(), '2026-10-30T19:00:00.000Z') +}) + +// ── The two DST rules, as decided ────────────────────────────────────────── + +test('a local time the spring gap swallows moves FORWARD to the first one that exists', () => { + // 2026-03-29 in Berlin: 02:00 becomes 03:00 and 02:30 never happens. The + // decision is the first valid instant — 03:00 — rather than "shift by the gap" + // (03:30): the event happens as close to the authored time as the calendar + // allows. + const resolved = r.resolveWall('Europe/Berlin', 2026, 3, 29, 2, 30) + assert.equal(resolved.adjusted, 'gap') + assert.equal(wall('Europe/Berlin', resolved.at), '2026-03-29 03:00') + assert.equal(resolved.at.toISOString(), '2026-03-29T01:00:00.000Z') + assert.equal(resolved.shiftMinutes, 30) +}) + +test('a local time that happens twice takes the FIRST of them', () => { + // 2026-10-25 in Berlin: 02:30 comes round at 00:30Z (+2, still CEST) and again + // at 01:30Z (+1, now CET). The first is the answer, and the second must not be + // — an event that fired at the later one would be an hour late by the clock + // the author wrote it against. + const resolved = r.resolveWall('Europe/Berlin', 2026, 10, 25, 2, 30) + assert.equal(resolved.adjusted, 'ambiguous') + assert.equal(resolved.at.toISOString(), '2026-10-25T00:30:00.000Z') + assert.equal(wall('Europe/Berlin', resolved.at), '2026-10-25 02:30') + + // Both instants really do read 02:30 — the fixture is only meaningful if the + // ambiguity is real. + const both = r.instantsForWall('Europe/Berlin', Date.UTC(2026, 9, 25, 2, 30)) + assert.equal(both.length, 2) + assert.equal(both[0], Date.UTC(2026, 9, 25, 0, 30)) + assert.equal(both[1], Date.UTC(2026, 9, 25, 1, 30)) +}) + +test('a weekly event in the gap still happens that week — it is never dropped', () => { + // The rule that makes the gap decision worth having. A Sunday 02:30 event in + // Berlin happens on 29 March like every other Sunday; it simply happens at + // 03:00. + const schedule = { kind: 'weekly', days: ['sunday'], time: '02:30' } + const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 6)) + assert.deepEqual(walls(found, 'Europe/Berlin'), [ + '2026-03-22 02:30', + '2026-03-29 03:00', + '2026-04-05 02:30', + ]) + assert.equal(found[1].adjusted, 'gap') + assert.equal(found[0].adjusted, null) +}) + +test('a thirty-minute DST shift resolves too — the gap is not always an hour', () => { + // Lord Howe Island shifts by 30 minutes (+10:30 to +11:00). 2026-10-04 has no + // 02:15 local. An implementation that assumed a whole-hour gap gets this wrong. + const resolved = r.resolveWall('Australia/Lord_Howe', 2026, 10, 4, 2, 15) + assert.equal(resolved.adjusted, 'gap') + assert.equal(wall('Australia/Lord_Howe', resolved.at), '2026-10-04 02:30') +}) + +test('a zone with no DST at all is left completely alone', () => { + // Asia/Kolkata is +05:30 all year, and the half hour is the point: an + // implementation carrying whole-hour offsets around would be 30 minutes out + // here, every day, without any transition to blame. + const schedule = { kind: 'weekly', days: ['friday'], time: '19:30' } + const found = r.occurrencesBetween(schedule, 'Asia/Kolkata', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 11)) + assert.equal(found.length, 4) + for (const o of found) { + assert.equal(o.adjusted, null) + assert.equal(wall('Asia/Kolkata', o.at).slice(11), '19:30') + assert.equal(o.at.toISOString().slice(11, 16), '14:00') + } +}) + +// ── The shapes ───────────────────────────────────────────────────────────── + +test('`once` produces its single occurrence, and only inside the window', () => { + const schedule = { kind: 'once', at: '2026-10-31T20:00' } + const inside = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 9, 1), Date.UTC(2026, 10, 1)) + assert.equal(inside.length, 1) + assert.equal(wall('Europe/Berlin', inside[0].at), '2026-10-31 20:00') + + const outside = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 10, 1), Date.UTC(2026, 11, 1)) + assert.deepEqual(outside, []) +}) + +test('`weekly` honours every named day, in week order', () => { + const schedule = { kind: 'weekly', days: ['saturday', 'wednesday'], time: '18:00' } + const found = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 5, 15)) + assert.deepEqual(walls(found, 'UTC'), [ + '2026-06-03 18:00', + '2026-06-06 18:00', + '2026-06-10 18:00', + '2026-06-13 18:00', + ]) +}) + +test('`monthly` with nth: -1 is the LAST weekday, which is not always the fourth', () => { + // The whole reason -1 exists. May 2026 has five Fridays and July 2026 has five; + // in those months "last" and "fourth" are different days, and a fishing contest + // on the last Friday is exactly that shape. + const last = r.occurrencesBetween( + { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:00' }, + 'UTC', + Date.UTC(2026, 4, 1), + Date.UTC(2026, 8, 1), + ) + const fourth = r.occurrencesBetween( + { kind: 'monthly', nth: 4, weekday: 'friday', time: '19:00' }, + 'UTC', + Date.UTC(2026, 4, 1), + Date.UTC(2026, 8, 1), + ) + // August 2026 has four Fridays, so "last" and "fourth" agree there and + // disagree in May and July. That the two lists share a member is the point: + // -1 is not a synonym for 4, and it is not a synonym for "different" either. + assert.deepEqual(walls(last, 'UTC'), [ + '2026-05-29 19:00', + '2026-06-26 19:00', + '2026-07-31 19:00', + '2026-08-28 19:00', + ]) + assert.deepEqual(walls(fourth, 'UTC'), [ + '2026-05-22 19:00', + '2026-06-26 19:00', + '2026-07-24 19:00', + '2026-08-28 19:00', + ]) + assert.notDeepEqual(walls(last, 'UTC'), walls(fourth, 'UTC')) +}) + +test('every month has a first through fourth of every weekday', () => { + // The claim the closed set rests on: because there is no `nth: 5`, there is no + // absent-occurrence case to define. Checked across three years rather than + // asserted in a comment. + for (let year = 2026; year <= 2028; year += 1) { + for (let month = 1; month <= 12; month += 1) { + for (let weekday = 0; weekday <= 6; weekday += 1) { + for (const nth of [1, 2, 3, 4, -1]) { + const day = r.nthWeekdayDay(year, month, weekday, nth) + assert.ok(day, `${year}-${month} weekday ${weekday} nth ${nth} should exist`) + } + } + } + } +}) + +test('`manual` is not a recurrence and expands to nothing', () => { + assert.deepEqual(r.occurrencesBetween({ kind: 'manual' }, 'UTC', Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1)), []) +}) + +// ── Bounds and refusals ──────────────────────────────────────────────────── + +test('an inverted or empty window answers with nothing rather than throwing', () => { + const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' } + assert.deepEqual(r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 4, 1)), []) + assert.deepEqual(r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 5, 1)), []) +}) + +test('a malformed schedule expands to nothing rather than to a wrong instant', () => { + // These shapes cannot come through `spec.js`, but they can come from a row + // written directly into the database — and the runner must not turn one into a + // world change at an invented time. + assert.deepEqual(r.occurrencesBetween({ kind: 'weekly', days: [], time: '20:00' }, 'UTC', 0, 1e12), []) + assert.deepEqual(r.occurrencesBetween({ kind: 'weekly', days: ['friday'], time: '25:00' }, 'UTC', 0, 1e12), []) + assert.deepEqual(r.occurrencesBetween({ kind: 'monthly', nth: 9, weekday: 'friday', time: '19:00' }, 'UTC', 0, 1e12), []) + assert.deepEqual(r.occurrencesBetween({ kind: 'once', at: 'tomorrow' }, 'UTC', 0, 1e12), []) + assert.deepEqual(r.occurrencesBetween(null, 'UTC', 0, 1e12), []) +}) + +test('the expansion is bounded, so a wide window cannot become an outage', () => { + const schedule = { + kind: 'weekly', + days: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'], + time: '12:00', + } + const found = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2020, 0, 1), Date.UTC(2030, 0, 1)) + assert.equal(found.length, r.MAX_OCCURRENCES) + + const smaller = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1), { limit: 10 }) + assert.equal(smaller.length, 10) +}) + +test('nextOccurrence looks forward and finds nothing when there is nothing', () => { + const weekly = r.nextOccurrence({ kind: 'weekly', days: ['friday'], time: '20:00' }, 'UTC', Date.UTC(2026, 5, 1)) + assert.equal(wall('UTC', weekly.at), '2026-06-05 20:00') + + // A `once` already in the past has no next occurrence, which is what stops a + // one-off event being re-materialised for ever. + assert.equal(r.nextOccurrence({ kind: 'once', at: '2020-01-01T12:00' }, 'UTC', Date.UTC(2026, 5, 1)), null) + assert.equal(r.nextOccurrence({ kind: 'manual' }, 'UTC', Date.UTC(2026, 5, 1)), null) +}) + +test('describe says the schedule back in words, in the event own zone', () => { + assert.equal( + r.describe({ kind: 'weekly', days: ['friday', 'saturday'], time: '20:00' }, 'Europe/Berlin'), + 'Every Friday and Saturday at 20:00 (Europe/Berlin)', + ) + assert.equal( + r.describe({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, 'Asia/Kolkata'), + 'The last Friday of every month at 19:30 (Asia/Kolkata)', + ) + assert.equal(r.describe({ kind: 'manual' }), 'Started by hand') +}) diff --git a/server/test/eventRunner.test.js b/server/test/eventRunner.test.js index 281cdd7..e9d5dbb 100644 --- a/server/test/eventRunner.test.js +++ b/server/test/eventRunner.test.js @@ -35,6 +35,7 @@ const runsDb = require('../src/model/events/eventRuns.db') const stepsDb = require('../src/model/events/eventRunSteps.db') const logDb = require('../src/model/events/eventRunLog.db') const versionsDb = require('../src/model/events/eventVersions.db') +const definitionsDb = require('../src/model/events/eventDefinitions.db') const db = require('../src/utils/db') after(() => db.close()) @@ -47,7 +48,7 @@ const later = (ms) => new Date(T0.getTime() + ms) let store const originals = {} -for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb]]) { +for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb]]) { originals[name] = { mod, fns: { ...mod } } } @@ -68,6 +69,14 @@ function installStubs() { nextStepId: 1, } + // Phase 4 put a schedule-expansion leg in front of the tick. This file is + // about what the runner does with runs that ALREADY exist, so it has nothing + // to expand — but the leg is a real query, and left unstubbed every `tick()` + // here would reach for the dead-port pool and wait on it. Answering with an + // empty list is what keeps this file measuring the runner rather than a + // connection timeout. + Object.assign(definitionsDb, { findSchedulable: async () => [] }) + // Snapshots, not live references. A SQL SELECT hands back a copy, and the // runner reads `step.attempts` as the value BEFORE its own claim incremented // it — returning references here would make the retry budget off by one in the diff --git a/server/test/eventRunnerSql.test.js b/server/test/eventRunnerSql.test.js index 51bdda3..01d4760 100644 --- a/server/test/eventRunnerSql.test.js +++ b/server/test/eventRunnerSql.test.js @@ -38,6 +38,22 @@ // unsettled seq instead, which is a different step whenever a phase carried // on past an `on_failure: skip` failure. // +// **Phase 4 added two reads**, and a read earns a place here when a stub cannot +// tell it is wrong: +// +// * **`findSchedulable`** - the query the runner runs on EVERY tick to decide +// what has a recurrence to expand. It joins a definition to its published +// version and left-joins the series, and every stub of it in +// `eventSchedule.test.js` is a hand-written object rather than that join. A +// syntax error or a wrong join direction here is a runner that materialises +// nothing, silently, for ever. +// * **`repinScheduled`** - the UPDATE publish runs over already-materialised +// occurrences. Its guard is the whole statement, and the two rows it must +// NOT touch are a run that has started and a run that is already terminal. +// * **`listInWindow`** - the calendar's real half, with a correlated subquery +// for `waiting_steps` and a LEFT JOIN that must not drop a definition with no +// series. +// // Plus the two unique indexes that are load-bearing rather than tidy: // `uq_evrun_occurrence` (which, not the claim, is what stops two runs of one // occurrence existing) and `uq_evstep_slot` (which is what makes re-materialising @@ -61,10 +77,29 @@ const mariadb = require('mariadb') // Trimmed to the columns these statements read or write. The ENUMs are verbatim, // because "is `missed` a legal value" is one of the things being proved. const SCHEMA = ` +CREATE TABLE event_series ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(160) NOT NULL, + slug VARCHAR(160) NOT NULL, + ordering INT NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE event_definitions ( id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(200) NOT NULL DEFAULT 'x', + slug VARCHAR(200) NOT NULL DEFAULT 'x', + state ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft', + current_version_id INT NULL, + series_id INT NULL, + concurrency_key VARCHAR(190) NULL, + timezone VARCHAR(64) NOT NULL DEFAULT 'UTC', grace_seconds INT NOT NULL DEFAULT 900 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE event_versions ( + id INT AUTO_INCREMENT PRIMARY KEY, + definition_id INT NOT NULL, + version INT NOT NULL DEFAULT 1, + spec JSON NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE event_runs ( id BIGINT AUTO_INCREMENT PRIMARY KEY, definition_id INT NOT NULL, @@ -207,6 +242,41 @@ SELECT r.id FROM event_runs r WHERE r.status = 'scheduled' AND r.scheduled_for + INTERVAL d.grace_seconds SECOND < ?` +// Verbatim `eventDefinitions.db#findSchedulable`. +const FIND_SCHEDULABLE = ` +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` + +// Verbatim `eventRuns.db#listInWindow`, with no optional filter applied. +const LIST_IN_WINDOW = ` +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 r.scheduled_for >= ? AND r.scheduled_for < ? + ORDER BY r.scheduled_for, r.id + LIMIT 500` + +// Verbatim `eventRuns.db#repinScheduled`. +const REPIN_SCHEDULED = ` +UPDATE event_runs + SET version_id = ? + WHERE definition_id = ? + AND status = 'scheduled' + AND started_at IS NULL + AND version_id <> ?` + const DB = `rg_events_test_${process.pid}` let pool = null let available = false @@ -274,6 +344,8 @@ beforeEach(async () => { await pool.query('DELETE FROM event_run_steps') await pool.query('DELETE FROM event_runs') await pool.query('DELETE FROM event_definitions') + await pool.query('DELETE FROM event_versions') + await pool.query('DELETE FROM event_series') }) async function seedRun(over = {}) { @@ -659,3 +731,163 @@ test('a guarded transition refuses a run that was cancelled underneath it', asyn assert.equal(rows(await pool.query(TRANSITION, ['running', 'two', runId, 'running'])), 0) assert.equal((await runById(runId)).status, 'cancelled') }) + + +// ── Phase 4: the two reads ──────────────────────────────────────── + +/** A definition with a published version, and optionally a series. */ +async function seedDefinition({ state = 'ready', spec = { schedule: { kind: 'manual' } }, series = null } = {}) { + let seriesId = null + if (series) { + const s = await pool.query('INSERT INTO event_series (name, slug) VALUES (?, ?)', [series, series]) + seriesId = s.insertId + } + const d = await pool.query( + 'INSERT INTO event_definitions (state, series_id, timezone) VALUES (?, ?, ?)', + [state, seriesId, 'Europe/Berlin'], + ) + const v = await pool.query( + 'INSERT INTO event_versions (definition_id, version, spec) VALUES (?, 1, ?)', + [d.insertId, JSON.stringify(spec)], + ) + await pool.query('UPDATE event_definitions SET current_version_id = ? WHERE id = ?', [ + v.insertId, + d.insertId, + ]) + return { definitionId: d.insertId, versionId: v.insertId, seriesId } +} + +test('findSchedulable returns ready definitions with their PUBLISHED spec, series or not', async (t) => { + if (needDb(t)) return + const withSeries = await seedDefinition({ + spec: { schedule: { kind: 'weekly', days: ['friday'], time: '20:00' } }, + series: 'royal-spy', + }) + const withoutSeries = await seedDefinition({ spec: { schedule: { kind: 'manual' } } }) + + const found = await pool.query(FIND_SCHEDULABLE) + const ids = found.map((r) => r.id).sort((a, b) => a - b) + assert.deepEqual(ids, [withSeries.definitionId, withoutSeries.definitionId].sort((a, b) => a - b)) + + // The LEFT JOIN must not drop the definition that belongs to no arc — an + // inner join here would make every event outside a series unschedulable, and + // most events are outside one. + const plain = found.find((r) => r.id === withoutSeries.definitionId) + assert.equal(plain.series_name, null) + + const arced = found.find((r) => r.id === withSeries.definitionId) + assert.equal(arced.series_name, 'royal-spy') + assert.equal(arced.timezone, 'Europe/Berlin') + + // The spec really came back, and really came back parseable. + const spec = typeof arced.version_spec === 'string' ? JSON.parse(arced.version_spec) : arced.version_spec + assert.equal(spec.schedule.kind, 'weekly') +}) + +test('findSchedulable skips a draft, an archived one, and one with no published version', async (t) => { + if (needDb(t)) return + await seedDefinition({ state: 'draft' }) + await seedDefinition({ state: 'archived' }) + // `ready` with a dangling version pointer: the JOIN is what must drop it, and + // a definition whose version row went missing must not become a runner crash. + const orphan = await seedDefinition({ state: 'ready' }) + await pool.query('DELETE FROM event_versions WHERE id = ?', [orphan.versionId]) + + assert.equal((await pool.query(FIND_SCHEDULABLE)).length, 0) +}) + +test('listInWindow is half-open on the window, and counts only PARKED steps as waiting', async (t) => { + if (needDb(t)) return + const def = await seedDefinition() + const at = async (when) => { + const r = await pool.query( + 'INSERT INTO event_runs (definition_id, version_id, scope, scheduled_for) VALUES (?, ?, ?, ?)', + [def.definitionId, def.versionId, '', when], + ) + return r.insertId + } + const before = await at(new Date('2026-09-01T00:00:00Z')) + const onFrom = await at(new Date('2026-09-02T00:00:00Z')) + const inside = await at(new Date('2026-09-05T00:00:00Z')) + const onTo = await at(new Date('2026-09-09T00:00:00Z')) + + // `>= from AND < to` — the instant ON the upper bound belongs to the NEXT + // window. A closed interval would draw the last day of one month and the first + // of the next as the same occurrence twice. + const found = await pool.query(LIST_IN_WINDOW, [ + new Date('2026-09-02T00:00:00Z'), + new Date('2026-09-09T00:00:00Z'), + ]) + assert.deepEqual(found.map((r) => r.id), [onFrom, inside]) + assert.ok(!found.some((r) => r.id === before || r.id === onTo)) + + // A parked step is `running` with a NULL lease; a leased one is the runner + // mid-dispatch and is not waiting on anybody. + await seedStep(inside, { status: 'running', claimExpiresAt: null, key: 'a'.repeat(40) }) + await seedStep(inside, { + seq: 1, + status: 'running', + claimedBy: 'host', + claimExpiresAt: later(60_000), + key: 'b'.repeat(40), + }) + const again = await pool.query(LIST_IN_WINDOW, [ + new Date('2026-09-02T00:00:00Z'), + new Date('2026-09-09T00:00:00Z'), + ]) + assert.equal(Number(again.find((r) => r.id === inside).waiting_steps), 1) +}) + + +test('repinScheduled moves the occurrences that have not begun, and only those', async (t) => { + if (needDb(t)) return + // The case: an editor fixes a typo on a weekly event on Wednesday. Two Fridays + // are already materialised on v3, last Friday's run is finished, and one is in + // flight right now. + const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (900)') + const mk = async (status, versionId, startedAt, when) => + ( + await pool.query( + `INSERT INTO event_runs (definition_id, version_id, scope, status, scheduled_for, started_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [def.insertId, versionId, `s${when}`, status, T0, startedAt], + ) + ).insertId + + const ahead1 = await mk('scheduled', 3, null, 1) + const ahead2 = await mk('scheduled', 3, null, 2) + const running = await mk('running', 3, T0, 3) + const done = await mk('completed', 3, T0, 4) + // Already on the new version: excluded by `version_id <> ?`, so a second + // publish of an unchanged definition is not a fleet of pointless writes. + const already = await mk('scheduled', 4, null, 5) + + const moved = rows(await pool.query(REPIN_SCHEDULED, [4, def.insertId, 4])) + assert.equal(moved, 2) + + const versionOf = async (id) => + Number((await pool.query('SELECT version_id FROM event_runs WHERE id = ?', [id]))[0].version_id) + assert.equal(await versionOf(ahead1), 4) + assert.equal(await versionOf(ahead2), 4) + // A run that has begun keeps the version it pinned, for ever: that pin is what + // makes it explicable afterwards. + assert.equal(await versionOf(running), 3) + assert.equal(await versionOf(done), 3) + assert.equal(await versionOf(already), 4) +}) + +test('a scheduled run whose started_at is somehow set is left alone', async (t) => { + if (needDb(t)) return + // Belt and braces on the guard: `status = 'scheduled'` and `started_at IS NULL` + // are two conditions rather than one because a row that has both is the only + // row that is provably untouched. + const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (900)') + const r = await pool.query( + `INSERT INTO event_runs (definition_id, version_id, scope, status, scheduled_for, started_at) + VALUES (?, 3, '', 'scheduled', ?, ?)`, + [def.insertId, T0, T0], + ) + assert.equal(rows(await pool.query(REPIN_SCHEDULED, [4, def.insertId, 4])), 0) + const after = (await pool.query('SELECT version_id FROM event_runs WHERE id = ?', [r.insertId]))[0] + assert.equal(Number(after.version_id), 3) +}) diff --git a/server/test/eventSchedule.test.js b/server/test/eventSchedule.test.js new file mode 100644 index 0000000..0b8652e --- /dev/null +++ b/server/test/eventSchedule.test.js @@ -0,0 +1,391 @@ +// ── Expansion and the calendar (EVENTS_PLAN.md Phase 4) ──────────────────── +// +// The phase's shipped claim: **a published definition with a recurrence produces +// occurrences on its own, and the calendar shows the ones that exist beside the +// ones that will.** The arithmetic underneath is proved separately in +// `eventRecurrence.test.js`; this file is about the two decisions the org lead +// took on 2026-09-02 and the properties they imply: +// +// • occurrences become REAL ROWS inside a fourteen-day horizon, and beyond it +// the calendar projects rather than materialising +// • a projection is never emitted for an instant a run already occupies — so +// the fortnight inside the horizon is not drawn twice, and a CANCELLED +// occurrence does not come back as a forecast +// • expansion looks forward from `now - grace` only, so an occurrence nobody +// could ever have seen is not invented retroactively +// • only `ready` definitions expand: publishing IS the schedule switch (§E) +// and archiving is how an operator turns one off +// • expansion is idempotent, because it runs every fifteen seconds for ever +// +// Stubbed at the `.db` layer, the shape `eventRunner.test.js` uses. + +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, afterEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const registries = require('../src/modules/registries') +const runner = require('../src/utils/eventRunner') +const calendarModel = require('../src/model/events/eventCalendar.model') +const definitionsDb = require('../src/model/events/eventDefinitions.db') +const runsDb = require('../src/model/events/eventRuns.db') +const stepsDb = require('../src/model/events/eventRunSteps.db') +const logDb = require('../src/model/events/eventRunLog.db') +const versionsDb = require('../src/model/events/eventVersions.db') +const db = require('../src/utils/db') + +after(() => db.close()) + +// A Tuesday. Chosen so a "friday" schedule has its first occurrence three days +// out — inside the horizon, but not today, which is what keeps "materialised" +// and "due" from being confusable in these fixtures. +const NOW = new Date('2026-09-01T12:00:00Z') + +const SPEC = { + schedule: { kind: 'weekly', days: ['friday'], time: '20:00' }, + phases: [{ key: 'main', label: 'Main', steps: [] }], +} + +let store +const originals = {} +for (const [name, mod] of [ + ['definitionsDb', definitionsDb], + ['runsDb', runsDb], + ['stepsDb', stepsDb], + ['logDb', logDb], + ['versionsDb', versionsDb], +]) { + originals[name] = { mod, fns: { ...mod } } +} + +const restoreOriginals = () => { + for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns) +} + +const clone = (o) => JSON.parse(JSON.stringify(o)) + +/** One `ready` definition with a published version carrying `spec`. */ +function addDefinition(id, overrides = {}) { + const definition = { + id, + title: `Event ${id}`, + slug: `event-${id}`, + state: 'ready', + timezone: 'UTC', + grace_seconds: 900, + concurrency_key: null, + current_version_id: id * 100, + series_id: null, + series_name: null, + series_slug: null, + spec: clone(SPEC), + ...overrides, + } + store.definitions.set(id, definition) + store.versions.set(definition.current_version_id, { + id: definition.current_version_id, + definition_id: id, + version: 1, + spec: definition.spec, + }) + return definition +} + +function installStubs() { + store = { definitions: new Map(), versions: new Map(), runs: [], steps: [], log: [], nextRunId: 1 } + + Object.assign(definitionsDb, { + findSchedulable: async () => + [...store.definitions.values()] + .filter((d) => d.state === 'ready' && d.current_version_id) + .map((d) => ({ ...d, version_spec: store.versions.get(d.current_version_id)?.spec || null })), + getById: async (id) => store.definitions.get(id) || null, + list: async () => [...store.definitions.values()], + }) + + Object.assign(versionsDb, { getById: async (id) => store.versions.get(id) || null }) + + Object.assign(runsDb, { + materialise: async (run) => { + const at = new Date(run.scheduled_for).getTime() + // The unique index, in memory: one row per (definition, scope, instant). + const clash = store.runs.find( + (r) => r.definition_id === run.definition_id && r.scope === (run.scope || '') && new Date(r.scheduled_for).getTime() === at, + ) + if (clash) return null + const id = store.nextRunId++ + const definition = store.definitions.get(run.definition_id) + store.runs.push({ + ...run, + id, + scope: run.scope || '', + status: 'scheduled', + health: 'ok', + waiting_steps: 0, + definition_title: definition?.title, + definition_slug: definition?.slug, + series_id: definition?.series_id ?? null, + series_name: definition?.series_name ?? null, + series_slug: definition?.series_slug ?? null, + version_number: 1, + }) + return id + }, + getById: async (id) => store.runs.find((r) => r.id === id) || null, + findOccurrence: async (definitionId, scope, at) => + store.runs.find( + (r) => r.definition_id === definitionId && r.scope === (scope || '') && new Date(r.scheduled_for).getTime() === new Date(at).getTime(), + ) || null, + listInWindow: async ({ from, to, status = null, scope = null, seriesId = null }) => + store.runs + .filter((r) => { + const at = new Date(r.scheduled_for).getTime() + if (at < new Date(from).getTime() || at >= new Date(to).getTime()) return false + if (status && r.status !== status) return false + if (scope !== null && scope !== undefined && r.scope !== scope) return false + if (seriesId && Number(r.series_id) !== Number(seriesId)) return false + return true + }) + .sort((a, b) => new Date(a.scheduled_for) - new Date(b.scheduled_for)), + }) + + Object.assign(stepsDb, { materialisePhase: async () => [] }) + Object.assign(logDb, { write: async (line) => { store.log.push(line); return 1 } }) +} + +beforeEach(() => { + registries._reset() + registries.registerCore() + installStubs() +}) + +afterEach(restoreOriginals) + +const instants = () => store.runs.map((r) => new Date(r.scheduled_for).toISOString()).sort() + +// ── Expansion ────────────────────────────────────────────────────────────── + +test('a weekly definition materialises exactly the occurrences inside the horizon', async () => { + addDefinition(1) + const created = await runner.expandSchedules(NOW) + + // 1 September 2026 is a Tuesday. Fridays inside 14 days: the 4th and the 11th. + assert.equal(created, 2) + assert.deepEqual(instants(), ['2026-09-04T20:00:00.000Z', '2026-09-11T20:00:00.000Z']) +}) + +test('expansion is idempotent — running it again creates nothing', async () => { + // The property the whole design leans on: this runs every fifteen seconds for + // ever. `INSERT IGNORE` against the occurrence key is what makes that free, + // and a second call that created rows would be a duplicate event, not a + // duplicate row. + addDefinition(1) + assert.equal(await runner.expandSchedules(NOW), 2) + assert.equal(await runner.expandSchedules(NOW), 0) + assert.equal(await runner.expandSchedules(new Date(NOW.getTime() + 60_000)), 0) + assert.equal(store.runs.length, 2) +}) + +test('only `ready` definitions expand — publishing is the switch, archiving turns it off', async () => { + addDefinition(1, { state: 'draft' }) + addDefinition(2, { state: 'archived' }) + addDefinition(3, { state: 'ready' }) + await runner.expandSchedules(NOW) + assert.deepEqual([...new Set(store.runs.map((r) => r.definition_id))], [3]) +}) + +test('a draft edit cannot materialise anything — the VERSION spec is what expands', async () => { + // The definition's working copy says daily; the published version says weekly. + // A half-typed recurrence an author is midway through must never produce a run. + const definition = addDefinition(1) + definition.spec = { + schedule: { kind: 'weekly', days: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'], time: '20:00' }, + phases: SPEC.phases, + } + await runner.expandSchedules(NOW) + assert.equal(store.runs.length, 2) +}) + +test('a manual definition expands to nothing at all', async () => { + addDefinition(1, { spec: { schedule: { kind: 'manual' }, phases: SPEC.phases } }) + store.versions.get(100).spec = store.definitions.get(1).spec + assert.equal(await runner.expandSchedules(NOW), 0) + assert.equal(store.runs.length, 0) +}) + +test('an occurrence older than the grace window is never materialised at all', async () => { + // Not materialised-then-swept. A row nobody could ever have seen or cancelled + // is not history, and writing one would put a `missed` event on the calendar + // for a date on which this deployment had no such event. The horizon is what + // makes the missed sweep meaningful instead: a real outage finds rows already + // there, because they were written a fortnight early. + addDefinition(1, { grace_seconds: 900 }) + // A Monday, three days after the Friday occurrence — far outside the grace. + await runner.expandSchedules(new Date('2026-09-07T12:00:00Z')) + assert.ok(!instants().includes('2026-09-04T20:00:00.000Z')) +}) + +test('an occurrence still inside the grace window IS materialised', async () => { + // The case this rule exists for: a definition published four minutes before + // its own first occurrence. `now - grace` is the window start, so the + // occurrence that has only just passed is still created and still startable. + addDefinition(1, { grace_seconds: 3600 }) + await runner.expandSchedules(new Date('2026-09-04T20:10:00Z')) + assert.ok(instants().includes('2026-09-04T20:00:00.000Z')) +}) + +test('a DST-adjusted occurrence records WHY its clock reads oddly', async () => { + // Discovering daylight saving at 3am on the last Sunday in October is the + // failure this line exists to prevent. + addDefinition(1, { + timezone: 'Europe/Berlin', + spec: { schedule: { kind: 'weekly', days: ['sunday'], time: '02:30' }, phases: SPEC.phases }, + }) + store.versions.get(100).spec = store.definitions.get(1).spec + + await runner.expandSchedules(new Date('2026-03-22T12:00:00Z')) + const adjusted = store.log.find((l) => l.detail?.dstAdjusted) + assert.equal(adjusted.detail.dstAdjusted, 'gap') + assert.equal(adjusted.detail.timezone, 'Europe/Berlin') + assert.ok(instants().includes('2026-03-29T01:00:00.000Z')) +}) + +test('a definition whose spec is nonsense is skipped, and the sweep carries on', async () => { + // A spec written straight into the database with a shape the validator would + // have refused is a bad row, not a bad tick. + addDefinition(1, { spec: { schedule: { kind: 'weekly', days: ['froday'], time: '20:00' }, phases: SPEC.phases } }) + store.versions.get(100).spec = store.definitions.get(1).spec + addDefinition(2) + + const created = await runner.expandSchedules(NOW) + assert.equal(created, 2) + assert.deepEqual([...new Set(store.runs.map((r) => r.definition_id))], [2]) +}) + +test('every materialised occurrence is marked as coming from the schedule', async () => { + // `started_by` is NULL for a scheduled occurrence and for one an admin started + // whose account has since gone, so the log is the only place the two are told + // apart. + addDefinition(1) + await runner.expandSchedules(NOW) + const created = store.log.filter((l) => l.kind === 'run.created' && l.detail?.source) + assert.equal(created.length, 2) + for (const line of created) { + assert.equal(line.detail.source, 'schedule') + assert.equal(line.detail.by, null) + } +}) + +// ── The calendar ─────────────────────────────────────────────────────────── + +test('inside the horizon the calendar shows runs; beyond it, projections', async () => { + addDefinition(1) + await runner.expandSchedules(NOW) + + const result = await calendarModel.calendar({ + from: new Date('2026-09-01T00:00:00Z'), + to: new Date('2026-10-01T00:00:00Z'), + now: NOW, + }) + + const kinds = result.entries.map((e) => `${e.kind} ${new Date(e.scheduledFor).toISOString().slice(0, 10)}`) + assert.deepEqual(kinds, [ + 'run 2026-09-04', + 'run 2026-09-11', + 'projected 2026-09-18', + 'projected 2026-09-25', + ]) + // The forecast is arithmetic and says so: no row, nothing to open. + for (const entry of result.entries.filter((e) => e.kind === 'projected')) { + assert.equal(entry.runId, null) + assert.equal(entry.status, null) + } +}) + +test('a projection is never drawn over an instant a run already occupies', async () => { + addDefinition(1) + await runner.expandSchedules(NOW) + const result = await calendarModel.calendar({ + from: new Date('2026-09-01T00:00:00Z'), + to: new Date('2026-09-15T00:00:00Z'), + now: NOW, + }) + assert.equal(result.entries.length, 2) + assert.ok(result.entries.every((e) => e.kind === 'run')) +}) + +test('a CANCELLED occurrence does not come back as a forecast', async () => { + // The same rule, and the case it earns its keep on. An operator who called an + // event off must not find it on the calendar again ten seconds later looking + // like it is still coming. + addDefinition(1) + await runner.expandSchedules(NOW) + store.runs[0].status = 'cancelled' + + const result = await calendarModel.calendar({ + from: new Date('2026-09-01T00:00:00Z'), + to: new Date('2026-09-15T00:00:00Z'), + now: NOW, + }) + const onTheDay = result.entries.filter((e) => new Date(e.scheduledFor).toISOString().startsWith('2026-09-04')) + assert.equal(onTheDay.length, 1) + assert.equal(onTheDay[0].kind, 'run') + assert.equal(onTheDay[0].status, 'cancelled') +}) + +test('a status filter suppresses projections, because a forecast has no status', async () => { + addDefinition(1) + await runner.expandSchedules(NOW) + const result = await calendarModel.calendar({ + from: new Date('2026-09-01T00:00:00Z'), + to: new Date('2026-10-01T00:00:00Z'), + status: 'scheduled', + now: NOW, + }) + assert.ok(result.entries.every((e) => e.kind === 'run')) + assert.equal(result.entries.length, 2) +}) + +test('a series filter narrows runs and projections alike', async () => { + addDefinition(1, { series_id: 7, series_name: 'Royal Spy Mission' }) + addDefinition(2, { series_id: 9, series_name: 'Something Else' }) + await runner.expandSchedules(NOW) + + const result = await calendarModel.calendar({ + from: new Date('2026-09-01T00:00:00Z'), + to: new Date('2026-10-01T00:00:00Z'), + seriesId: 7, + now: NOW, + }) + assert.ok(result.entries.length > 2) + assert.ok(result.entries.every((e) => e.seriesName === 'Royal Spy Mission')) + assert.ok(result.entries.some((e) => e.kind === 'projected')) +}) + +test('the window is bounded, inverted windows are refused, and the horizon is reported', async () => { + const wide = await calendarModel.calendar({ + from: new Date('2026-01-01T00:00:00Z'), + to: new Date('2027-01-01T00:00:00Z'), + now: NOW, + }) + assert.equal(wide.ok, false) + assert.equal(wide.status, 400) + assert.match(wide.errors.join(' '), /at most 92 days/) + + const inverted = await calendarModel.calendar({ + from: new Date('2026-09-10T00:00:00Z'), + to: new Date('2026-09-01T00:00:00Z'), + now: NOW, + }) + assert.equal(inverted.ok, false) + + const fine = await calendarModel.calendar({ + from: new Date('2026-09-01T00:00:00Z'), + to: new Date('2026-09-15T00:00:00Z'), + horizonDays: 14, + now: NOW, + }) + assert.equal(fine.ok, true) + assert.equal(fine.horizon.toISOString(), '2026-09-15T12:00:00.000Z') +}) diff --git a/server/test/eventSeries.test.js b/server/test/eventSeries.test.js new file mode 100644 index 0000000..b733639 --- /dev/null +++ b/server/test/eventSeries.test.js @@ -0,0 +1,113 @@ +// ── Event series, the arc (EVENTS.md §D/§I, Phase 4) ─────────────────────── +// +// One small table, and the reason it is worth testing at all is the two rules +// that are not obvious from its four columns: +// +// • the slug is derived once and FROZEN. The public arc page lives at it, so +// a rename that moved it would break every link — including the ones inside +// the Discord posts this feature will eventually write. +// • the delete is a real delete, and it is the only one in this feature. A +// definition is archived instead, because a run pins its version and history +// that cannot be explained defeats the audit. A series pins nothing: it is a +// label, `series_id` is ON DELETE SET NULL, and the count of what it detached +// is what an operator needs to be told. + +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, afterEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const seriesDb = require('../src/model/events/eventSeries.db') +const series = require('../src/model/events/eventSeries.model') +const db = require('../src/utils/db') + +after(() => db.close()) + +let store +const original = { ...seriesDb } + +beforeEach(() => { + store = { rows: new Map(), nextId: 1 } + Object.assign(seriesDb, { + list: async () => [...store.rows.values()].sort((a, b) => a.ordering - b.ordering || a.id - b.id), + getById: async (id) => store.rows.get(id) || null, + exists: async (id) => store.rows.has(id), + insert: async (s) => { + const id = store.nextId++ + store.rows.set(id, { ...s, id, definition_count: 0 }) + return id + }, + update: async (id, s) => { + const existing = store.rows.get(id) + store.rows.set(id, { ...existing, ...s }) + return 1 + }, + remove: async (id) => { + store.rows.delete(id) + return 1 + }, + }) +}) + +afterEach(() => Object.assign(seriesDb, original)) + +test('a series is created with a slug derived from its name', async () => { + const result = await series.create({ name: 'Royal Spy Mission', ordering: 2 }, 7) + assert.equal(result.ok, true) + assert.equal(result.status, 201) + assert.equal(result.series.slug, 'royal-spy-mission') + assert.equal(result.series.ordering, 2) + assert.equal(result.series.created_by, 7) +}) + +test('two series with the same name get distinct slugs', async () => { + // `slug` is UNIQUE in the schema, so without this the second create is a 1452 + // reaching a controller as a 500. + const first = await series.create({ name: 'Winter Arc' }) + const second = await series.create({ name: 'Winter Arc' }) + assert.equal(first.series.slug, 'winter-arc') + assert.equal(second.series.slug, 'winter-arc-2') +}) + +test('renaming a series does NOT move its slug', async () => { + // The rule with teeth. The arc page lives at the slug, and a rename is the + // ordinary act of an editor tidying up wording months later. + const created = await series.create({ name: 'Royal Spy Mission' }) + const updated = await series.update(created.series.id, { name: 'The Royal Spy Missions' }) + assert.equal(updated.ok, true) + assert.equal(updated.series.name, 'The Royal Spy Missions') + assert.equal(updated.series.slug, 'royal-spy-mission') +}) + +test('a nameless series is refused, and so is a nonsense ordering', async () => { + assert.deepEqual((await series.create({ name: ' ' })).errors, ['name is required']) + const bad = await series.create({ name: 'Fine', ordering: -3 }) + assert.equal(bad.ok, false) + assert.match(bad.errors.join(' '), /ordering must be an integer/) +}) + +test('deleting a series answers with how many definitions it detached', async () => { + // The whole consequence of this delete is about the rows it does NOT delete, + // so the count is the answer rather than a detail. + const created = await series.create({ name: 'Winter Arc' }) + store.rows.get(created.series.id).definition_count = 3 + + const removed = await series.remove(created.series.id) + assert.equal(removed.ok, true) + assert.equal(removed.detached, 3) + assert.equal(await seriesDb.getById(created.series.id), null) +}) + +test('acting on a series that is not there is a 404, never a 500', async () => { + assert.equal((await series.update(999, { name: 'x' })).status, 404) + assert.equal((await series.remove(999)).status, 404) +}) + +test('ordering places a series among the others, and defaults to zero', async () => { + await series.create({ name: 'Third', ordering: 30 }) + await series.create({ name: 'First', ordering: 10 }) + await series.create({ name: 'Unordered' }) + const listed = await series.list() + assert.deepEqual(listed.map((s) => s.name), ['Unordered', 'First', 'Third']) +}) diff --git a/server/test/eventSpec.test.js b/server/test/eventSpec.test.js index 307cdad..5d995f6 100644 --- a/server/test/eventSpec.test.js +++ b/server/test/eventSpec.test.js @@ -192,13 +192,104 @@ test('a key a later phase owns is refused, not silently preserved', () => { assert.match(phase.errors.join('\n'), /unknown key\(s\) advance .*Phase 5/) }) -test('only the manual schedule exists in this phase', () => { - const weekly = spec.validate({ - schedule: { kind: 'weekly', days: ['fri'], time: '20:00' }, - phases: [{ key: 'main', label: 'Main', steps: [] }], +// ── The schedule shapes (Phase 4) ──────────────────────────────────── +// +// Every check here is on SHAPE. What the shapes MEAN — the zone arithmetic, the +// DST rules — is `eventRecurrence.test.js`. The split is deliberate: this file +// answers "may this be saved", that one answers "when does it happen", and the +// second question is only worth asking of something that passed the first. + +const withSchedule = (schedule) => + spec.validate({ schedule, phases: [{ key: 'main', label: 'Main', steps: [] }] }) + +test('the four closed shapes are accepted and normalised', () => { + assert.deepEqual(withSchedule({ kind: 'manual' }).spec.schedule, { kind: 'manual' }) + assert.deepEqual(withSchedule({ kind: 'once', at: '2026-10-31T20:00' }).spec.schedule, { + kind: 'once', + at: '2026-10-31T20:00', }) - assert.equal(weekly.ok, false) - assert.match(weekly.errors.join('\n'), /recurrence arrives in Phase 4/) + assert.deepEqual(withSchedule({ kind: 'weekly', days: ['friday'], time: '20:00' }).spec.schedule, { + kind: 'weekly', + days: ['friday'], + time: '20:00', + }) + assert.deepEqual( + withSchedule({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }).spec.schedule, + { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, + ) +}) + +test('the validator accepts its own output for every shape', () => { + // Phase 1's rule, and it is a rule about the SECOND save of any definition + // rather than about a round trip for its own sake: `validate` normalises, and + // publish re-validates what a save wrote. A normaliser that refuses what it + // emits makes a published definition uneditable. + for (const schedule of [ + { kind: 'manual' }, + { kind: 'once', at: '2026-10-31T20:00' }, + { kind: 'weekly', days: ['friday', 'monday'], time: '20:00' }, + { kind: 'monthly', nth: 4, weekday: 'friday', time: '19:30' }, + ]) { + const first = withSchedule(schedule) + assert.equal(first.ok, true, JSON.stringify(schedule)) + const second = withSchedule(first.spec.schedule) + assert.equal(second.ok, true, JSON.stringify(first.spec.schedule)) + assert.deepEqual(second.spec.schedule, first.spec.schedule) + } +}) + +test('weekly days are normalised into week order and deduped', () => { + // Not tidiness. The spec is snapshotted into a version and diffed, so two + // orderings of the same schedule would show as an edit nobody made. + const result = withSchedule({ kind: 'weekly', days: ['Friday', 'monday', 'FRIDAY'], time: '20:00' }) + assert.deepEqual(result.spec.schedule.days, ['monday', 'friday']) +}) + +test('a shape may not carry another shape keys', () => { + const result = withSchedule({ kind: 'weekly', days: ['friday'], time: '20:00', at: '2026-01-01T00:00' }) + assert.equal(result.ok, false) + assert.match(result.errors.join('\n'), /unknown key\(s\) at for kind "weekly"/) +}) + +test('an unknown kind is refused, and the message names the four', () => { + const result = withSchedule({ kind: 'daily', time: '20:00' }) + assert.equal(result.ok, false) + assert.match(result.errors.join('\n'), /manual, once, weekly, monthly/) +}) + +test('a date that is not a real day is refused', () => { + // The regex admits 2026-02-30 quite happily. A schedule that parses and then + // resolves to some other day is worse than one that is refused. + const result = withSchedule({ kind: 'once', at: '2026-02-30T20:00' }) + assert.equal(result.ok, false) + assert.match(result.errors.join('\n'), /is not a real date/) +}) + +test('every malformed schedule field is named, not merely rejected', () => { + assert.match(withSchedule({ kind: 'once', at: 'soon' }).errors.join('\n'), /YYYY-MM-DDTHH:MM/) + assert.match(withSchedule({ kind: 'weekly', days: [], time: '20:00' }).errors.join('\n'), /non-empty array/) + assert.match(withSchedule({ kind: 'weekly', days: ['froday'], time: '20:00' }).errors.join('\n'), /unknown weekday/) + assert.match(withSchedule({ kind: 'weekly', days: ['friday'], time: '25:00' }).errors.join('\n'), /24-hour time/) + assert.match( + withSchedule({ kind: 'monthly', nth: 5, weekday: 'friday', time: '19:30' }).errors.join('\n'), + /1, 2, 3, 4 or -1/, + ) + assert.match( + withSchedule({ kind: 'monthly', nth: 1, weekday: 'froday', time: '19:30' }).errors.join('\n'), + /expected one of sunday/, + ) +}) + +test('a refused schedule leaves a manual one behind rather than half a recurrence', () => { + // `validate` collects every error and carries on, so the spec object exists + // even when the answer is no. A caller reading `schedule.days` of it must not + // find a partially built weekly. + const result = spec.validate({ + schedule: { kind: 'weekly', days: ['froday'], time: '20:00' }, + phases: [{ key: 'BAD KEY', label: '', steps: [] }], + }) + assert.equal(result.ok, false) + assert.ok(result.errors.length > 1) }) test('every problem is reported, not just the first', () => { diff --git a/server/test/eventsAdmin.test.js b/server/test/eventsAdmin.test.js index 3f589a9..62ccb82 100644 --- a/server/test/eventsAdmin.test.js +++ b/server/test/eventsAdmin.test.js @@ -160,6 +160,21 @@ function installStubs() { .filter((r) => (!definitionId || r.definition_id === definitionId) && (!status || r.status === status)) .map(shapeRun) runsDb.getById = async (id) => (store.runs.has(id) ? shapeRun(store.runs.get(id)) : undefined) + runsDb.listScheduledFor = async (definitionId) => + [...store.runs.values()] + .filter((r) => r.definition_id === definitionId && r.status === 'scheduled' && !r.started_at) + .map((r) => ({ id: r.id, version_id: r.version_id, scheduled_for: r.scheduled_for })) + runsDb.repinScheduled = async (definitionId, versionId) => { + let moved = 0 + for (const run of store.runs.values()) { + if (run.definition_id !== definitionId) continue + if (run.status !== 'scheduled' || run.started_at) continue + if (run.version_id === versionId) continue + run.version_id = versionId + moved += 1 + } + return moved + } runsDb.materialise = async (run) => { const key = occurrenceKey(run.definition_id, run.scope, run.scheduled_for) if (store.occurrences.has(key)) return null // the UNIQUE index, doing its job @@ -595,3 +610,56 @@ test('the list filters by state, and an unknown id is 404 rather than 500', asyn const bad = await call(ctrl.get, { params: { id: 'not-a-number' } }) assert.equal(bad.statusCode, 400) }) + + +test('publishing re-pins the occurrences that have not started, and says how many', async () => { + // The case an operator meets on their SECOND edit of any recurring event: a + // fortnight of occurrences is already on the calendar, each carrying the spec + // as it was. Left alone, an edit reaches none of them and the only recourse -- + // cancelling each -- makes the occurrence vanish rather than come back, because + // a cancelled row still holds its slot in `uq_evrun_occurrence`. + const created = await createDraft() + const id = created.body.event.id + await call(ctrl.publish, { params: { id: String(id) } }) + + const ahead = await call(ctrl.startRun, { + params: { id: String(id) }, + body: { scope: 'ahead', scheduledFor: '2026-12-24T20:00:00Z' }, + }) + assert.equal(ahead.statusCode, 201) + const aheadId = ahead.body.run.id + const v1 = store.runs.get(aheadId).version_id + + // A second occurrence, this one already under way. Its pin is what makes it + // explicable afterwards, so it must not move. + const inFlight = await call(ctrl.startRun, { + params: { id: String(id) }, + body: { scope: 'inflight', scheduledFor: '2026-12-25T20:00:00Z' }, + }) + const inFlightId = inFlight.body.run.id + store.runs.get(inFlightId).status = 'running' + store.runs.get(inFlightId).started_at = new Date() + + const republished = await call(ctrl.publish, { params: { id: String(id) } }) + assert.equal(republished.statusCode, 200) + assert.equal(republished.body.version, 2) + assert.equal(republished.body.repinned, 1) + + assert.equal(store.runs.get(aheadId).version_id, republished.body.versionId) + assert.notEqual(store.runs.get(aheadId).version_id, v1) + assert.equal(store.runs.get(inFlightId).version_id, v1) + + // The move is on the run's own log, because "which version did this actually + // use" is the first question an audit asks. + const line = store.log.find((l) => l.run_id === aheadId && l.detail?.repinned) + assert.equal(line.detail.fromVersionId, v1) + assert.equal(line.detail.toVersionId, republished.body.versionId) +}) + +test('re-publishing with nothing scheduled ahead re-pins nothing', async () => { + const created = await createDraft() + const id = created.body.event.id + await call(ctrl.publish, { params: { id: String(id) } }) + const again = await call(ctrl.publish, { params: { id: String(id) } }) + assert.equal(again.body.repinned, 0) +})