feat(events): the integrations — lifecycle triggers, participants, results (Phase 10)
`EVENTS_PLAN.md` Phase 10. Core registers its own `event.` triggers, records who took part, publishes a results table, and announces a post through the legs the news pipeline already uses. Events owns none of the delivery: a run says what happened and an operator's rule decides who is told, so email, the in-app inbox, push tickles, Discord and the town crier all arrive without anything in `events/` growing a second delivery path. **No route was added and nothing moved.** The whole surface is two more derived fields on a run — `participants` and `resultsPublishedAt` — and a zero-line `routes.manifest.json` diff proves it. Seven triggers: six at ceiling `authenticated` / audience `subscribers`, exactly where `news.post` sits, and `run.failed` at `admin` on both halves. Every one keys its cooldown on the RUN. Two rules seeded, both off, under a third one-shot key so a deployment that has already stamped the Team and news keys still gets them. **The phase's own defect was a promise nothing kept.** `EVENTS.md` §I says a rehearsal runs for real "with announcements ceilinged to `staff`" — but a ceiling is declared on the TRIGGER, and a rehearsal fires the same trigger as the real thing, so the moment this phase gave a run something to announce, rehearsing a published event would have mailed every subscriber. The emit envelope now takes an optional `ceiling` and the send-time G24 gate applies `meet(declared, emitted)`. It only narrows; two incomparable ceilings refuse every rule rather than resolving to either. `MODULE_API_VERSION` stays 1.10.0, amended in place — `main` declares 1.9.0, so 1.10.0 has not shipped and the org lead's 2026-09-03 rule applies for the third time. Three defects the live walk found, none visible to a unit test: 1. **A channel that reported success while reaching nobody.** The seeded `run.started` rule named `push`, because §8.5 and the plan both do. Push delivery joins `notification_subscriptions`, only ever written for an id the preferences screen offered push for — and it offers push only for a registered STREAM. So the tickle went nowhere every time while `pushChannel.deliver` answered "tickle published". `event.run.started` is now a stream as well as a trigger; the other six are not. 2. **A trigger's `description` reaches a recipient.** It is the structural projection's `intro` fallback, so `run.failed`'s line ending "Staff-facing." put those words in an administrator's own inbox item. 3. **`affectedRows` cannot tell an insert from an unchanged upsert.** The connector sends `CLIENT_FOUND_ROWS`, so a "was this new" flag would have counted every idempotent retried collect as a fresh participant. And one caught before it shipped: ranking with a session variable is wrong here, because `query()` takes a pool connection per call — the variable would be set on one connection and read on another. A window function needs no session state. ## Verification - `npm test --prefix server` — **1981 pass, 1 fail**, and that one (`botScore.test.js`) passes standalone at 18/18: a file-level flake under parallel load. Run with an empty `MODULES_DIR`, as CI does. - `npm test --prefix client` — 362 pass, 0 fail. `npm run build` green. - Zero-line `routes.manifest.json` / `routes.guards.json` diff. - A live walk on a real rig: MariaDB, the site with no module, mailpit. The mail arrived, headed with the event's title and its start time in the shard's own zone; the rehearsal fired the same trigger and produced zero outbox rows where the real run produced three; `run.failed` reached the administrator's inbox and no player's; `core.announce.post` queued a second job without touching the news pipeline's back-pointer or `announced_at`; and `rankRun` and the upsert were run against real MariaDB 11. ## One thing for a reviewer, out of scope and not fixed **Every `#swagger.description` in this repo is truncated in the generated spec.** swagger-autogen does not honour a backslash-escaped apostrophe, so a description is cut at the first `\'` — 175 of the 177 in `server/src/router/**`. It is pre-existing and repo-wide. Only the one annotation this phase edits is fixed here (a typographic apostrophe), because otherwise this phase's own addition to it would be dead text. The rest wants its own change. - [x] AI-assisted: Claude Code (Opus 5). Docs: RunicGateway/docs#TBD. Co-Authored-By: Claude <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
225
server/src/events/announce.js
Normal file
225
server/src/events/announce.js
Normal file
@@ -0,0 +1,225 @@
|
||||
// ── 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.
|
||||
hour12: true,
|
||||
}).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,
|
||||
definition,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
@@ -61,7 +61,8 @@ function withDeadline(fn, ms, actionId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a raw `perform()` answer into `{ outcome, error?, holdSeconds?, resources? }`.
|
||||
* Turn a raw `perform()` answer into
|
||||
* `{ outcome, error?, holdSeconds?, resources?, participants? }`.
|
||||
*
|
||||
* Exported and pure, so the classification rules are testable without a registry,
|
||||
* a database or a clock — which matters because they are the rules that decide
|
||||
@@ -97,7 +98,12 @@ function classify(result, actionId) {
|
||||
// never names a verb. `core.cue` and `core.wait` reach them through the same
|
||||
// door Phase 7 opens to a module's own long-running action.
|
||||
if (result.await === 'human') {
|
||||
return { outcome: 'parked', error: null, resources: result.resources || [] }
|
||||
return {
|
||||
outcome: 'parked',
|
||||
error: null,
|
||||
resources: result.resources || [],
|
||||
participants: result.participants || [],
|
||||
}
|
||||
}
|
||||
|
||||
let holdSeconds = 0
|
||||
@@ -109,7 +115,17 @@ function classify(result, actionId) {
|
||||
holdSeconds = Math.min(Math.floor(n), MAX_HOLD_SECONDS)
|
||||
}
|
||||
|
||||
return { outcome: 'done', error: null, holdSeconds, resources: result.resources || [] }
|
||||
// `participants` rides beside `resources` and on the same two success shapes
|
||||
// (Phase 10). It is carried rather than interpreted here: what a member key
|
||||
// means is the module's business, and this file's whole job is to know
|
||||
// nothing about the verb it just called.
|
||||
return {
|
||||
outcome: 'done',
|
||||
error: null,
|
||||
holdSeconds,
|
||||
resources: result.resources || [],
|
||||
participants: result.participants || [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
162
server/src/events/participants.js
Normal file
162
server/src/events/participants.js
Normal file
@@ -0,0 +1,162 @@
|
||||
// ── Recording who took part ────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §J, and Phase 10 of EVENTS_PLAN.md. The twin of
|
||||
// `events/ledger.js`: that file records what a run did to the world, this one
|
||||
// records who it happened to.
|
||||
//
|
||||
// **Participants ride the SAME envelope resources do** (org lead, 2026-09-04). An
|
||||
// action answers `{ ok: true, participants: [...] }` and the runner writes them
|
||||
// beside the resources, on the same two success shapes, through the same
|
||||
// classify → record path. There is no `ctx.events.participants` API and no route:
|
||||
// a second write path into a run core is mid-tick on would be a second thing that
|
||||
// can race the claim, for a caller that does not exist until a module can source
|
||||
// the data at all (Phase 11's plugin-side participation ledger; Phase 12's
|
||||
// collect step is the first consumer).
|
||||
//
|
||||
// **Core cannot source a participant and does not try.** §J: `member_key` is
|
||||
// module-opaque, `user_id` is filled in by whoever knows the link table. For
|
||||
// module-uo that is `shard_links`; for another game it is something else, and a
|
||||
// core that guessed would be one game's identity model compiled into core. So a
|
||||
// module reports both halves, or reports the key alone and the row stays
|
||||
// anonymous — which is the honest record of an unlinked player who turned up.
|
||||
//
|
||||
// **A bad entry is dropped, never a retry.** Exactly `ledger.normalise`'s
|
||||
// posture and for exactly its reason: a malformed participant will be just as
|
||||
// malformed on the second attempt, and failing the step would re-dispatch a
|
||||
// world write that already happened. Rejections are logged and surfaced on the
|
||||
// run log so an author can see what their module sent.
|
||||
|
||||
const participantsDb = require('../model/events/eventRunParticipants.db')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// Bounded to the column, and refused rather than truncated: a truncated member
|
||||
// key is a different participant, and under `uq_evpart_member` it would silently
|
||||
// merge two people into one row.
|
||||
const MAX_MEMBER_KEY = 190
|
||||
|
||||
// The most one step may report. A run's participants are people, and a step
|
||||
// answering with a hundred thousand of them is a module bug rather than a very
|
||||
// popular event — one that would otherwise spend a tick's whole budget on
|
||||
// inserts while holding the step's claim. `MAX_AUDIENCE` in the engagement
|
||||
// engine is 5000 for the same class of reason and this matches it deliberately:
|
||||
// the two bound the same thing, a list of users one call may assert.
|
||||
const MAX_PER_STEP = 5000
|
||||
|
||||
/**
|
||||
* Turn one entry of a module's `participants` array into a row, or say why not.
|
||||
*/
|
||||
function normalise(entry, actionId) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return { ok: false, reason: `${actionId} reported a participant that is not an object` }
|
||||
}
|
||||
|
||||
const memberKey = String(entry.memberKey === undefined || entry.memberKey === null ? '' : entry.memberKey)
|
||||
if (!memberKey || memberKey.length > MAX_MEMBER_KEY) {
|
||||
return { ok: false, reason: `${actionId} reported a participant with a bad memberKey "${entry.memberKey}"` }
|
||||
}
|
||||
|
||||
// Optional, and checked rather than coerced. A `userId` is a foreign key into
|
||||
// `users`, so a module that passed a character serial here would either fail
|
||||
// the insert or — worse, if the number happened to be a real user — attribute
|
||||
// somebody else's attendance to a stranger.
|
||||
let userId = null
|
||||
if (entry.userId !== undefined && entry.userId !== null) {
|
||||
if (!Number.isInteger(entry.userId) || entry.userId < 1) {
|
||||
return { ok: false, reason: `${actionId} reported a participant with a bad userId "${entry.userId}"` }
|
||||
}
|
||||
userId = entry.userId
|
||||
}
|
||||
|
||||
// `score` is optional and defaults to 0 — a run that only records attendance
|
||||
// is a run where everybody scored nothing, which is a true statement and a
|
||||
// renderable table. Non-finite is refused rather than coerced: `NaN` written
|
||||
// into a DECIMAL would either throw at the driver or land as 0, and a 0 that
|
||||
// meant "the module sent nonsense" is indistinguishable from an honest zero.
|
||||
let score = 0
|
||||
if (entry.score !== undefined && entry.score !== null) {
|
||||
const n = Number(entry.score)
|
||||
if (!Number.isFinite(n)) {
|
||||
return { ok: false, reason: `${actionId} reported a participant with a bad score "${entry.score}"` }
|
||||
}
|
||||
score = n
|
||||
}
|
||||
|
||||
let joinedAt = null
|
||||
if (entry.joinedAt !== undefined && entry.joinedAt !== null) {
|
||||
const at = entry.joinedAt instanceof Date ? entry.joinedAt : new Date(entry.joinedAt)
|
||||
if (Number.isNaN(at.getTime())) {
|
||||
return { ok: false, reason: `${actionId} reported a participant with a bad joinedAt "${entry.joinedAt}"` }
|
||||
}
|
||||
joinedAt = at
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
row: {
|
||||
memberKey,
|
||||
userId,
|
||||
score,
|
||||
// Opaque, exactly like a resource's payload: core stores it and never
|
||||
// reads it. Anything but an object is dropped rather than refused —
|
||||
// `meta` is decoration on a row whose identity is already valid, and
|
||||
// losing an event's whole attendance over a stray string would be the
|
||||
// wrong trade.
|
||||
meta: entry.meta && typeof entry.meta === 'object' && !Array.isArray(entry.meta) ? entry.meta : null,
|
||||
joinedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what a module said about who took part.
|
||||
*
|
||||
* Answers `{ recorded, rejected }`. Never throws, for `recordAnswer`'s reason: a
|
||||
* step that changed the world has changed it, and a bookkeeping failure must not
|
||||
* become a retry of a world write.
|
||||
*/
|
||||
async function recordAnswer({ run, step, action, participants }) {
|
||||
const out = { recorded: 0, rejected: [] }
|
||||
const list = Array.isArray(participants) ? participants : []
|
||||
if (!list.length) return out
|
||||
|
||||
if (list.length > MAX_PER_STEP) {
|
||||
// Refused whole rather than truncated. Half a leaderboard silently cut at
|
||||
// five thousand is worse than none: the table would look complete and be
|
||||
// wrong, and nothing downstream could tell.
|
||||
const reason = `${action.id} reported ${list.length} participants, more than the ${MAX_PER_STEP} one step may`
|
||||
log.warn('event participants refused', { run: run.id, step: step.id, reason })
|
||||
return { recorded: 0, rejected: [reason] }
|
||||
}
|
||||
|
||||
// **Deduplicated in memory before the write.** One step reporting the same
|
||||
// member twice is a module bug, and letting both reach the upsert would make
|
||||
// the LAST one win silently. Refusing the step would be worse — the other
|
||||
// ninety-nine participants are fine — so the first wins and the duplicate is
|
||||
// named, which is a thing an author can act on.
|
||||
const seen = new Set()
|
||||
|
||||
for (const entry of list) {
|
||||
const parsed = normalise(entry, action.id)
|
||||
if (!parsed.ok) {
|
||||
out.rejected.push(parsed.reason)
|
||||
log.warn('event participant rejected', { run: run.id, step: step.id, reason: parsed.reason })
|
||||
continue
|
||||
}
|
||||
if (seen.has(parsed.row.memberKey)) {
|
||||
out.rejected.push(`${action.id} reported "${parsed.row.memberKey}" twice in one step`)
|
||||
continue
|
||||
}
|
||||
seen.add(parsed.row.memberKey)
|
||||
try {
|
||||
await participantsDb.record({ runId: run.id, ...parsed.row })
|
||||
out.recorded += 1
|
||||
} catch (err) {
|
||||
out.rejected.push(err.message)
|
||||
log.error('event participant insert failed', { run: run.id, step: step.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { normalise, recordAnswer, MAX_MEMBER_KEY, MAX_PER_STEP }
|
||||
Reference in New Issue
Block a user