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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user