Files
website/server/src/events/announce.js
wtclaude 7d7840eb6b
All checks were successful
PR Checks / client-build (pull_request) Successful in 40s
PR Checks / bot-tests (pull_request) Successful in 41s
PR Checks / server-tests (pull_request) Successful in 5m56s
fix(events): midnight in an announcement is 12:00 am on the Node we ship
`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
2026-09-09 19:20:31 -05:00

259 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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,
}