`startsAtLabel` asked for `hour12: true` on `en-GB`. That is not the same request as a 12-hour clock, and it does not survive a Node upgrade: for a locale whose default cycle is h23, Node 20 resolves `hour12: true` to h11, whose hours run 0-11, so midnight renders "0:00 am". Node 22 and later resolve it to h12 and it renders "12:00 am". Same ICU on both sides, so it is V8's ECMA-402 behaviour rather than locale data. The image ships node:20-alpine and CI runs Node 20, while a dev machine is newer -- which is how this rendered correctly in front of everyone who wrote it and wrongly for every real recipient. An event mail announcing a midnight start said "0:00 am" while the schedule editor beside it said "12:00 AM": one instant, two spellings, which is the exact contradiction the option was added to prevent. `hourCycle: 'h12'` is the request that means what was meant. `recurrence.js` already states the mirror-image rule for `h23`, and every other formatter in this repo and in module-uo uses `hourCycle`; there is no `hour12` left here. This is the one test that has been red on every events PR since #192, and the only one -- each of those runs reported `# fail 1`. Verified by running the suite under node:20-alpine, where the test fails without this change and 2152 tests pass with it; on Node 22+ it passes either way, so the test's comment now says that a green run on a dev machine is not evidence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
259 lines
12 KiB
JavaScript
259 lines
12 KiB
JavaScript
// ── A run's lifecycle, told to the engagement engine ───────────────────────
|
||
//
|
||
// EVENTS.md §J, and Phase 10 of EVENTS_PLAN.md. Seven moments in a run's life
|
||
// become seven `event.` triggers, and **Events owns none of the delivery**.
|
||
//
|
||
// That sentence is the whole design and it is worth being exact about what it
|
||
// buys. Nothing in this file knows what email is, whether anyone is subscribed,
|
||
// what a template says, or how often somebody may be mailed. It says a thing
|
||
// happened, with the facts the declaration asked for; an operator's rule decides
|
||
// the rest. Every announcement channel the platform has — email, the in-app
|
||
// inbox, content-free push tickles, Discord and the town crier through the
|
||
// announce legs — arrives for free the day a rule points at one, and none of
|
||
// them arrives by anything in `events/` growing a second delivery path.
|
||
//
|
||
// **Nothing here throws and nothing here is awaited for its answer.** `emit`
|
||
// itself is fire-and-forget by construction (see `engagementEmit`'s header) —
|
||
// the whole point of the seam is that the emitter does not wait on rule lookups
|
||
// and a dozen inserts. What IS awaited here is the read that assembles the
|
||
// payload, and it is wrapped: a run must not fail to start because the row that
|
||
// says what it is called could not be read.
|
||
//
|
||
// **A rehearsal narrows the ceiling rather than staying silent.** §I: "run for
|
||
// real with announcements ceilinged to `staff`". Every emit below carries
|
||
// `ceiling: 'staff'` when the run is a rehearsal, so the same triggers fire, the
|
||
// same rules are evaluated, the same log lines are written — and the only rules
|
||
// that survive the G24 gate are ones whose audience a staff member is in. A
|
||
// rehearsal that emitted nothing would be a rehearsal of everything except the
|
||
// announcements, which are the part most worth rehearsing.
|
||
|
||
const definitionsDb = require('../model/events/eventDefinitions.db')
|
||
const participantsDb = require('../model/events/eventRunParticipants.db')
|
||
const logDb = require('../model/events/eventRunLog.db')
|
||
const engagementEmit = require('../utils/engagementEmit')
|
||
const log = require('../utils/logger')('events')
|
||
|
||
// §I, and the one place a rehearsal differs from the real thing on the announce
|
||
// path. `staff` rather than `admin` because a rehearsal is the event team's
|
||
// dress run and a moderator on it should see what an attendee would.
|
||
const REHEARSAL_CEILING = 'staff'
|
||
|
||
/**
|
||
* The start time written out in the shard-local zone, for a mail to read.
|
||
*
|
||
* **A presentational fragment computed at the emitter** (ENGAGEMENT.md §4.6.1
|
||
* convention 1). `startsAt` also goes down the wire as a `datetime`, which the
|
||
* seam normalises to an ISO string — right as data and wrong in a sentence — and
|
||
* a template has no logic with which to format one. The zone is the shard's own,
|
||
* because "8pm" means the shard's evening to everyone reading it and the
|
||
* recipient's browser is not in the room when a mail is rendered.
|
||
*
|
||
* A bad zone answers null rather than throwing: `Intl` rejects an unknown
|
||
* identifier, and an event whose timezone column holds a typo must still
|
||
* announce. The variable is optional and its block is one token, so an absent
|
||
* label renders as nothing at all rather than as a broken line.
|
||
*/
|
||
function startsAtLabel(at, zone) {
|
||
const when = at instanceof Date ? at : new Date(at)
|
||
if (Number.isNaN(when.getTime())) return undefined
|
||
try {
|
||
const text = new Intl.DateTimeFormat('en-GB', {
|
||
timeZone: zone || 'UTC',
|
||
weekday: 'long',
|
||
day: 'numeric',
|
||
month: 'long',
|
||
hour: 'numeric',
|
||
minute: '2-digit',
|
||
// Explicit rather than left to the locale, because `en-GB` would otherwise
|
||
// render midnight as "00:00" while the schedule editor beside it writes
|
||
// "12:00 AM" — one event, two spellings of the same instant.
|
||
//
|
||
// `hourCycle: 'h12'` and NOT `hour12: true`, which is not the same request
|
||
// and does not survive a Node upgrade. For a locale whose default cycle is
|
||
// h23 — `en-GB` is one — Node 20 resolves `hour12: true` to **h11**, whose
|
||
// hours run 0–11, so midnight comes out "0:00 am"; Node 22 and later
|
||
// resolve it to h12 and it comes out "12:00 am". Same ICU on both, so this
|
||
// is V8's ECMA-402 behaviour and not locale data, and the image ships
|
||
// node:20-alpine while a dev machine is newer — which is how this rendered
|
||
// correctly in front of everyone who wrote it and wrongly for every real
|
||
// recipient. `recurrence.js` states the mirror-image rule for `h23`; there
|
||
// is no `hour12` left in this repo and it should stay that way.
|
||
hourCycle: 'h12',
|
||
}).format(when)
|
||
return `${text} (${zone || 'UTC'})`
|
||
} catch {
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The facts every `event.` trigger shares, read once per emit.
|
||
*
|
||
* A run row from `findDue` is `SELECT *` over `event_runs` alone — no title, no
|
||
* series, no summary — so the definition is fetched here rather than threaded
|
||
* through every call site in the runner. It is one indexed read per lifecycle
|
||
* transition, which is a handful per run.
|
||
*/
|
||
async function baseFor(run) {
|
||
const definition = await definitionsDb.getById(run.definition_id)
|
||
if (!definition) return null
|
||
return {
|
||
runId: String(run.id),
|
||
title: definition.title,
|
||
summary: definition.summary || undefined,
|
||
seriesName: definition.series_name || undefined,
|
||
timezone: run.timezone || definition.timezone || undefined,
|
||
eventUrl: eventUrl(definition, run),
|
||
definition,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The public page for one occurrence (Phase 14a).
|
||
*
|
||
* **Site-relative, and it carries the run.** The page lives at the definition's
|
||
* slug — one stable address for a weekly event, which is what makes a link in
|
||
* Discord survive a retitle — so the occurrence has to be in the query string or
|
||
* a mail about last Friday's invasion would open next Friday's.
|
||
*
|
||
* **`undefined` when the event is not public**, rather than a path that answers
|
||
* 404. An unlisted or not-yet-`ready` definition has no page, and `eventUrl` is
|
||
* declared optional precisely so its block can disappear from a template instead
|
||
* of rendering a dead button. That is `news.post`'s lesson applied before it
|
||
* costs anything: a link nobody can follow is worse than no link, because it
|
||
* advertises one.
|
||
*/
|
||
function eventUrl(definition, run) {
|
||
if (!definition.slug) return undefined
|
||
if (definition.state !== 'ready' || !definition.listed) return undefined
|
||
return `/site/events/${encodeURIComponent(definition.slug)}?run=${run.id}`
|
||
}
|
||
|
||
/**
|
||
* Fire one lifecycle trigger.
|
||
*
|
||
* `extra` is merged over the shared facts and may drop any of them — a payload
|
||
* key set to `undefined` is simply absent, and `validatePayload` treats absent
|
||
* and null alike, so a trigger that declares fewer variables than this assembles
|
||
* is not a problem: undeclared keys are dropped at the seam and logged as a
|
||
* debug line rather than refused.
|
||
*
|
||
* Answers nothing. Every caller is a transition in the runner and none of them
|
||
* has anything it could correctly do with a failure of core's own notification
|
||
* bookkeeping.
|
||
*/
|
||
async function fire(run, triggerId, extra = {}) {
|
||
try {
|
||
const base = await baseFor(run)
|
||
if (!base) {
|
||
// The definition is gone. `event_runs.definition_id` cascades on delete, so
|
||
// this is a race with an archive rather than an ordinary state — nothing to
|
||
// announce and nothing broken.
|
||
return
|
||
}
|
||
const { definition, ...facts } = base
|
||
const ceiling = run.rehearsal ? REHEARSAL_CEILING : undefined
|
||
|
||
engagementEmit.emit('core', triggerId, {
|
||
// The run, not the definition. Two occurrences of a weekly event are two
|
||
// subjects, so last week's mail does not throttle this week's — and within
|
||
// one run a cooldown means "at most one line an hour about THIS", which is
|
||
// the sentence an operator writing `phase.changed` actually wants.
|
||
subject: String(run.id),
|
||
// Bounded and stable, because it ends up in a signed unsubscribe token that
|
||
// will sit in a mailbox for months. A run id is both.
|
||
scopeKey: `event:${run.id}`,
|
||
ceiling,
|
||
data: { ...facts, ...extra },
|
||
})
|
||
|
||
// Written here rather than at each call site: what an operator wants in the
|
||
// run log is that the run SAID something happened, and with what bound. How
|
||
// many people were told is the engagement engine's own log line and its own
|
||
// decision — a run log that claimed to know the number would be reporting a
|
||
// decision it does not make.
|
||
await logDb.write({
|
||
runId: run.id,
|
||
kind: 'announcement.emitted',
|
||
phase: run.current_phase || null,
|
||
detail: { trigger: triggerId, ...(ceiling ? { ceiling, because: 'rehearsal' } : {}) },
|
||
})
|
||
} catch (err) {
|
||
log.error('lifecycle announcement failed', { run: run.id, trigger: triggerId, message: err.message })
|
||
}
|
||
}
|
||
|
||
// ── One function per moment, so the runner names a moment and not a payload ──
|
||
//
|
||
// The alternative — `fire(run, 'event.run.started', { … })` at each call site —
|
||
// would put the payload assembly in `eventRunner.js`, where a change to a
|
||
// declaration becomes a change to the runner. These are the seam.
|
||
|
||
const runScheduled = (run) =>
|
||
fire(run, 'event.run.scheduled', {
|
||
startsAt: run.scheduled_for,
|
||
startsAtLabel: startsAtLabel(run.scheduled_for, run.timezone),
|
||
})
|
||
|
||
const runStarted = (run, startedAt) => {
|
||
const at = startedAt || run.started_at || new Date()
|
||
return fire(run, 'event.run.started', { startsAt: at, startsAtLabel: startsAtLabel(at, run.timezone) })
|
||
}
|
||
|
||
const phaseChanged = (run, { phase, label, index, count }) =>
|
||
fire(run, 'event.phase.changed', {
|
||
phase,
|
||
phaseLabel: label || phase,
|
||
// One-based, because it is read by a human in a sentence. Every caller
|
||
// passes the zero-based index it already has and the conversion is here, in
|
||
// one place, rather than at three call sites where two of them would drift.
|
||
phaseIndex: index + 1,
|
||
phaseCount: count,
|
||
})
|
||
|
||
const runEnding = (run) => fire(run, 'event.run.ending')
|
||
|
||
async function runCompleted(run, endedAt) {
|
||
// Counted at emit rather than carried by the caller: the last thing a run does
|
||
// before completing is its teardown, and a module's collect step may have
|
||
// written rows within the same tick.
|
||
let participantCount = 0
|
||
try {
|
||
participantCount = await participantsDb.countForRun(run.id)
|
||
} catch (err) {
|
||
// Declared `required`, so it has to be a number. Zero is the honest answer
|
||
// for a count that could not be read, and it is also the answer for the far
|
||
// more common case of a run nothing collected for.
|
||
log.warn('participant count unavailable for announcement', { run: run.id, message: err.message })
|
||
}
|
||
const started = run.started_at ? new Date(run.started_at) : null
|
||
const ended = endedAt ? new Date(endedAt) : new Date()
|
||
const durationMinutes = started ? Math.max(0, Math.round((ended - started) / 60_000)) : 0
|
||
return fire(run, 'event.run.completed', { participantCount, durationMinutes })
|
||
}
|
||
|
||
const runCancelled = (run, reason) =>
|
||
fire(run, 'event.run.cancelled', { reason: reason || undefined })
|
||
|
||
const runFailed = (run, error) =>
|
||
fire(run, 'event.run.failed', {
|
||
phase: run.current_phase || undefined,
|
||
error: error || run.last_error || undefined,
|
||
// The one destination that exists today. See `coreTriggers.js`'s note above
|
||
// the six public declarations for why none of them has one.
|
||
runUrl: `/admin/events/runs/${run.id}`,
|
||
})
|
||
|
||
module.exports = {
|
||
fire,
|
||
startsAtLabel,
|
||
runScheduled,
|
||
runStarted,
|
||
phaseChanged,
|
||
runEnding,
|
||
runCompleted,
|
||
runCancelled,
|
||
runFailed,
|
||
REHEARSAL_CEILING,
|
||
}
|