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