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