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:
@@ -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