// The deterministic check. Pure functions: the page and the tests call exactly these. // It reconciles data it could read and states the absence of an expected result on a defined scope. // It never concludes on a source it could not read completely. import type { CalendarEvent, CheckConfig, Contact, IsoDate, Message, Snapshot, SourceKey, } from "./types"; const MIN = 60_000; const t = (d: IsoDate) => new Date(d).getTime(); const addMinutes = (d: IsoDate, m: number) => new Date(t(d) + m * MIN).toISOString(); export type FindingKind = | "no_qualifying_reply" | "personalization_defect" | "personalization_risk" | "contradictory_followup" | "time_in_stage" | "no_matching_reminder" | "no_native_reminder_configured" | "frequency_to_check" | "expected_workflow_not_active"; export interface Mark { at: IsoDate; label: string; role: "input" | "expected" | "found" | "check"; } export interface Finding { kind: FindingKind; tier: "primary" | "secondary"; contactId?: string; subjectId: string; // message, event, opportunity or workflow the finding is about marks: Mark[]; detail: Record; } export type SetAsideReason = | "waiting_agreed" | "no_authorized_channel" | "late_booking" | "appointment_cancelled"; export interface SetAside { reason: SetAsideReason; check: FindingKind; contactId: string; subjectId: string; marks: Mark[]; detail: Record; } export interface CannotConclude { check: FindingKind; contactId?: string; subjectId?: string; missing: SourceKey[]; reason: string; } export interface CollectionProblem { source: SourceKey | "run"; problem: "partial" | "not_read" | "forbidden" | "stale_run" | "dated_after_read"; note: string; } export interface Report { checkedAt: IsoDate; collectedAt: IsoDate; collectionProblems: CollectionProblem[]; findings: Finding[]; setAside: SetAside[]; cannotConclude: CannotConclude[]; counts: { exceptions: number; // findings contactsConcerned: number; // distinct contacts behind the findings contactsSetAside: number; // distinct contacts, whatever the number of checks that set them aside cannotConclude: number; }; verdict: "exceptions_found" | "nothing_to_report" | "incomplete"; } // ---------- collection ---------- export function sourceOk(s: Snapshot, key: SourceKey): boolean { return s.sources.find((x) => x.source === key)?.status === "complete"; } export function assessCollection(s: Snapshot, cfg: CheckConfig, now: IsoDate): CollectionProblem[] { const out: CollectionProblem[] = []; const all: SourceKey[] = [ "contacts", "forms.submissions", "messages.nonEmail", "messages.email", "calendars.events", "calendars.notifications", "opportunities", "workflows", ]; for (const key of all) { const read = s.sources.find((x) => x.source === key); if (!read) out.push({ source: key, problem: "not_read", note: "Not requested in this run." }); else if (read.status !== "complete") out.push({ source: key, problem: read.status, note: read.note ?? "" }); } const later = [ ...s.messages.map((m) => m.dateAdded), ...s.submissions.map((x) => x.createdAt), ...s.events.map((e) => e.dateAdded), ...s.opportunities.map((o) => o.createdAt), ].filter((d) => t(d) > t(s.collectedAt)).length; if (later) out.push({ source: "run", problem: "dated_after_read", note: `${later} records are dated after the data was read. They are not used; clocks or collection need a look.` }); const ageH = (t(now) - t(s.collectedAt)) / (60 * MIN); if (ageH > cfg.staleRunAfterHours) out.push({ source: "run", problem: "stale_run", note: `Data collected ${Math.round(ageH)} h before this report.` }); return out; } // ---------- helpers ---------- /** The check knows the account only up to the moment the data was read. Absences are measured to that * moment, never to the time the report is displayed. */ export function horizon(s: Snapshot, now: IsoDate): IsoDate { return t(s.collectedAt) < t(now) ? s.collectedAt : now; } const NOT_SENT = new Set(["failed", "undelivered", "opt_out", "scheduled", "pending", "queued"]); const AUTOMATED = new Set(["workflow", "campaign", "bulk_actions", "api"]); /** A call counts as a reply only when someone spoke with the contact. The rule is ours, stated on the page. */ const CALL_REACHED = new Set(["completed", "answered"]); /** The spec's enum says TYPE_SMS, its example says SMS: both forms are accepted until a real response settles it. */ export function channelOf(m: Message): "SMS" | "Email" | "Call" | null { const k = String(m.messageType).replace(/^TYPE_/, "").toUpperCase(); if (k === "SMS") return "SMS"; if (k === "EMAIL") return "Email"; if (k === "CALL") return "Call"; return null; } export function isSent(m: Message): boolean { return !(m.status && NOT_SENT.has(m.status)); } export function allowedChannels(c: Contact): ("SMS" | "Email" | "Call")[] { if (c.dnd) return []; return (["SMS", "Email", "Call"] as const).filter((ch) => c.dndSettings?.[ch]?.status !== "active"); } export function holdUntil(c: Contact, cfg: CheckConfig): IsoDate | null { return c.customFields?.find((f) => f.id === cfg.holdUntilFieldId)?.value ?? null; } /** A reply that answers the inquiry: sent to the contact, on a channel they allow, after the inquiry, and either * written by a person, or an automated message that matches an expected reply defined in the settings. * An automated send we cannot tie to the inquiry (newsletter, other workflow) is not a reply. * A call counts only when the contact was reached (meta.callStatus completed or answered). */ export function isQualifyingReply(m: Message, contact: Contact, after: IsoDate, cfg: CheckConfig): boolean { if (m.direction !== "outbound" || m.contactId !== contact.id) return false; const ch = channelOf(m); if (ch === null || !allowedChannels(contact).includes(ch)) return false; if (t(m.dateAdded) < t(after)) return false; if (!isSent(m)) return false; if (ch === "Call") return CALL_REACHED.has(m.meta?.callStatus ?? ""); if (m.source === "app") return true; if (m.source && AUTOMATED.has(m.source)) return m.source === "workflow" && cfg.expectedAutoReplyMarkers.some((k) => m.body?.includes(k)); return false; // unknown source: not assumed to be a reply } /** Only what existed when the data was read is used as evidence. */ function readable(items: T[], at: (x: T) => IsoDate, limit: IsoDate): T[] { return items.filter((x) => t(at(x)) <= t(limit)); } /** Which sources a conclusion of absence needs, for this contact. */ function sourcesForAbsence(contact: Contact): SourceKey[] { const need: SourceKey[] = ["messages.nonEmail"]; if (allowedChannels(contact).includes("Email")) need.push("messages.email"); return need; } // ---------- check 1: expected inquiries and first reply ---------- /** Expected inquiries come from the normal entry (form submissions), never from the workflow being watched, * and never from contact.dateAdded (imports, returning clients, recruiting all break that). */ export function expectedInquiries(s: Snapshot, cfg: CheckConfig) { return s.submissions .filter((f) => cfg.inquiryFormIds.includes(f.formId)) .map((f) => ({ submission: f, contact: s.contacts.find((c) => c.id === f.contactId) })); } export function checkFirstReply(s: Snapshot, cfg: CheckConfig, reportTime: IsoDate) { const now = horizon(s, reportTime); const findings: Finding[] = []; const setAside: SetAside[] = []; const cannot: CannotConclude[] = []; if (!sourceOk(s, "forms.submissions") || !sourceOk(s, "contacts")) { cannot.push({ check: "no_qualifying_reply", missing: (["forms.submissions", "contacts"] as SourceKey[]).filter((k) => !sourceOk(s, k)), reason: "The list of expected inquiries could not be built.", }); return { findings, setAside, cannot }; } const messages = readable(s.messages, (m) => m.dateAdded, now); for (const { submission, contact } of expectedInquiries(s, cfg)) { if (t(submission.createdAt) > t(now)) continue; if (!contact) { // A submission whose contact is missing never disappears silently. cannot.push({ check: "no_qualifying_reply", subjectId: submission.id, missing: [], reason: "An inquiry form submission points to a contact that was not in the data.", }); continue; } const due = addMinutes(submission.createdAt, cfg.firstReplyWithinMinutes); const inquiryMarks: Mark[] = [ { at: submission.createdAt, label: "Inquiry form submitted", role: "input" as const }, { at: due, label: "First reply due", role: "expected" as const }, ]; if (allowedChannels(contact).length === 0) { setAside.push({ reason: "no_authorized_channel", check: "no_qualifying_reply", contactId: contact.id, subjectId: submission.id, marks: inquiryMarks, detail: {}, }); continue; } const replies = messages .filter((m) => isQualifyingReply(m, contact, submission.createdAt, cfg)) .sort((a, b) => t(a.dateAdded) - t(b.dateAdded)); if (replies.length > 0) continue; // answered (possibly late: not a primary case) if (t(now) < t(due)) continue; // not due yet const hold = holdUntil(contact, cfg); if (hold && t(hold) > t(now)) { setAside.push({ reason: "waiting_agreed", check: "no_qualifying_reply", contactId: contact.id, subjectId: submission.id, marks: inquiryMarks, detail: { holdUntil: hold }, }); continue; } const missing = sourcesForAbsence(contact).filter((k) => !sourceOk(s, k)); if (missing.length) { cannot.push({ check: "no_qualifying_reply", contactId: contact.id, subjectId: submission.id, missing, reason: "No reply was found, but a channel the contact allows was not read completely.", }); continue; } const failed = messages.filter( (m) => m.contactId === contact.id && m.direction === "outbound" && t(m.dateAdded) >= t(submission.createdAt) && (m.status === "failed" || m.status === "undelivered"), ); const oppCreated = s.opportunities.find((o) => o.contactId === contact.id && t(o.createdAt) >= t(submission.createdAt)); const marks: Mark[] = [...inquiryMarks]; if (oppCreated) marks.splice(1, 0, { at: oppCreated.createdAt, label: "Opportunity created", role: "input" as const }); for (const f of failed) marks.push({ at: f.dateAdded, label: `Outbound ${channelOf(f)} ${f.status}`, role: "found" as const }); marks.push({ at: now, label: "Data read: no qualifying reply", role: "check" as const }); findings.push({ kind: "no_qualifying_reply", tier: "primary", contactId: contact.id, subjectId: submission.id, marks: marks.sort((a, b) => t(a.at) - t(b.at)), detail: { minutesPastDue: Math.round((t(now) - t(due)) / MIN), failedAttempts: failed.length, opportunityCreated: !!oppCreated, channelsRead: sourcesForAbsence(contact).join(", "), }, }); } return { findings, setAside, cannot }; } // ---------- check 2: personalization ---------- /** The name expected between the greeting prefix and the next comma. Returns null when the template's * greeting cannot be located in the body (then nothing is concluded from it). */ export function renderedGreetingName(body: string, prefix: string): string | null { const i = body.indexOf(prefix.trimEnd()); if (i !== 0) return null; const rest = body.slice(prefix.trimEnd().length); const comma = rest.indexOf(","); if (comma < 0) return null; return rest.slice(0, comma).trim(); } export function checkPersonalization(s: Snapshot, cfg: CheckConfig, reportTime: IsoDate) { const now = horizon(s, reportTime); const findings: Finding[] = []; const cannot: CannotConclude[] = []; for (const tpl of cfg.personalizedTemplates) { // Observed defect: the stored text of a sent message. for (const m of readable(s.messages, (x) => x.dateAdded, now)) { if (m.direction !== "outbound" || !isSent(m)) continue; const contact = s.contacts.find((c) => c.id === m.contactId); if (!contact) continue; if (m.body === undefined) { // A body we could not read is never a clean message. Only relevant for automated sends. if (m.source === "workflow") cannot.push({ check: "personalization_defect", contactId: m.contactId, subjectId: m.id, missing: [], reason: "The message body was not returned, so its text could not be checked.", }); continue; } if (!m.body.includes(tpl.marker)) continue; const name = renderedGreetingName(m.body, tpl.greetingPrefix); if (name === null || name.length > 0) continue; findings.push({ kind: "personalization_defect", tier: "primary", contactId: contact.id, subjectId: m.id, marks: [ ...bookingMarks(s, contact.id, cfg), { at: m.dateAdded, label: "Confirmation sent with a blank greeting", role: "found" as const }, { at: now, label: "Data read", role: "check" as const }, ].sort((a, b) => t(a.at) - t(b.at)), detail: { template: tpl.name, body: m.body, firstNameOnFile: contact.firstName ?? "", source: m.source ?? "" }, }); } // Risk: contacts about to receive the template without the field it needs. if (!sourceOk(s, "opportunities") || !sourceOk(s, "contacts")) { cannot.push({ check: "personalization_risk", missing: ["opportunities", "contacts"].filter((k) => !sourceOk(s, k as SourceKey)) as SourceKey[], reason: "Upcoming sends could not be listed." }); continue; } const alreadyFlagged = new Set(findings.map((f) => f.contactId)); for (const o of s.opportunities) { if (o.status !== "open" || !tpl.atRiskStageIds.includes(o.pipelineStageId)) continue; const contact = s.contacts.find((c) => c.id === o.contactId); if (!contact || alreadyFlagged.has(contact.id)) continue; if ((contact[tpl.requiredContactField] ?? "").trim() === "") findings.push({ kind: "personalization_risk", tier: "secondary", contactId: contact.id, subjectId: o.id, marks: [{ at: o.lastStageChangeAt, label: "In a stage that leads to this message", role: "input" as const }], detail: { template: tpl.name, field: tpl.requiredContactField }, }); } } return { findings, cannot }; } function bookingMarks(s: Snapshot, contactId: string, cfg: CheckConfig): Mark[] { const ev = s.events.find((e) => e.contactId === contactId && e.calendarId === cfg.bookingAsk.calendarId); return ev ? [{ at: ev.dateAdded, label: "Home consultation booked", role: "input" as const }] : []; } // ---------- check 3: contradictory follow-up ---------- // Booked and not cancelled. "showed" and "noshow" mean the appointment was still on when it started. const HELD_APPT = new Set(["new", "confirmed", "showed", "noshow"]); export function checkContradiction(s: Snapshot, cfg: CheckConfig, now: IsoDate) { const findings: Finding[] = []; const setAside: SetAside[] = []; const cannot: CannotConclude[] = []; if (!sourceOk(s, "calendars.events")) { cannot.push({ check: "contradictory_followup", missing: ["calendars.events"], reason: "Bookings could not be read." }); return { findings, setAside, cannot }; } for (const m of readable(s.messages, (x) => x.dateAdded, horizon(s, now))) { if (m.direction !== "outbound" || !m.body?.includes(cfg.bookingAsk.marker)) continue; if (!isSent(m)) continue; const bookings = s.events.filter( (e) => e.contactId === m.contactId && e.calendarId === cfg.bookingAsk.calendarId && t(e.dateAdded) < t(m.dateAdded), ); const live = bookings.find((e) => HELD_APPT.has(e.appointmentStatus) && t(e.startTime) > t(m.dateAdded)); if (live) { findings.push({ kind: "contradictory_followup", tier: "primary", contactId: m.contactId, subjectId: m.id, marks: [ { at: live.dateAdded, label: "Home consultation booked", role: "input" as const }, { at: m.dateAdded, label: "Follow-up asks them to book", role: "found" as const }, { at: live.startTime, label: "Consultation", role: "expected" as const }, ].sort((a, b) => t(a.at) - t(b.at)), detail: { body: m.body, source: m.source ?? "", appointmentStatus: live.appointmentStatus }, }); continue; } const cancelled = bookings.find((e) => e.appointmentStatus === "cancelled"); if (cancelled && t(cancelled.dateUpdated) > t(m.dateAdded) && t(cancelled.startTime) > t(m.dateAdded)) { // Cancelled now, but last changed after the message: its status when the message went out is unknown. cannot.push({ check: "contradictory_followup", contactId: m.contactId, subjectId: m.id, missing: [], reason: "The appointment is cancelled now, but was last changed after this message; check its status when the message went out.", }); continue; } const unclear = bookings.find((e) => !HELD_APPT.has(e.appointmentStatus) && e.appointmentStatus !== "cancelled" && t(e.startTime) > t(m.dateAdded)); if (unclear) { cannot.push({ check: "contradictory_followup", contactId: m.contactId, subjectId: m.id, missing: [], reason: `Appointment status "${unclear.appointmentStatus}" does not say whether it was on when the message went out.` }); continue; } if (cancelled) setAside.push({ reason: "appointment_cancelled", check: "contradictory_followup", contactId: m.contactId, subjectId: m.id, marks: [ { at: cancelled.dateAdded, label: "Consultation booked", role: "input" as const }, { at: cancelled.dateUpdated, label: "Cancelled", role: "input" as const }, { at: m.dateAdded, label: "Follow-up asks them to book again", role: "found" as const }, ], detail: {}, }); } return { findings, setAside, cannot }; } // ---------- secondary: time in stage ---------- export function checkTimeInStage(s: Snapshot, cfg: CheckConfig, reportTime: IsoDate) { const now = horizon(s, reportTime); const findings: Finding[] = []; const setAside: SetAside[] = []; const cannot: CannotConclude[] = []; if (!sourceOk(s, "opportunities")) { cannot.push({ check: "time_in_stage", missing: ["opportunities"], reason: "Opportunities could not be read." }); return { findings, setAside, cannot }; } for (const o of s.opportunities) { const limit = cfg.stageLimitsDays[o.pipelineStageId]; if (o.status !== "open" || !limit) continue; const days = (t(now) - t(o.lastStageChangeAt)) / (24 * 60 * MIN); if (days <= limit.days) continue; const marks: Mark[] = [ { at: o.lastStageChangeAt, label: `Moved to ${limit.label}`, role: "input" as const }, { at: addMinutes(o.lastStageChangeAt, limit.days * 24 * 60), label: `${limit.days} days in stage`, role: "expected" as const }, { at: now, label: "Data read", role: "check" as const }, ]; if (!sourceOk(s, "contacts")) { // Without the contact we cannot see an agreed wait: that is not the same as no agreed wait. cannot.push({ check: "time_in_stage", contactId: o.contactId, subjectId: o.id, missing: ["contacts"], reason: "Over the stage limit, but the contact (and any agreed wait) could not be read." }); continue; } const contact = s.contacts.find((c) => c.id === o.contactId); const hold = contact ? holdUntil(contact, cfg) : null; if (hold && t(hold) > t(now)) { setAside.push({ reason: "waiting_agreed", check: "time_in_stage", contactId: o.contactId, subjectId: o.id, marks, detail: { holdUntil: hold, days: Math.floor(days), stage: limit.label, limitDays: limit.days }, }); continue; } findings.push({ kind: "time_in_stage", tier: "secondary", contactId: o.contactId, subjectId: o.id, marks, detail: { days: Math.floor(days), stage: limit.label, limitDays: limit.days }, }); } return { findings, setAside, cannot }; } // ---------- secondary: appointment reminders ---------- const UNIT_MIN = { minutes: 1, hours: 60, days: 1440 } as const; export function checkReminders(s: Snapshot, cfg: CheckConfig, reportTime: IsoDate) { const now = horizon(s, reportTime); const findings: Finding[] = []; const setAside: SetAside[] = []; const cannot: CannotConclude[] = []; if (!sourceOk(s, "calendars.events")) { cannot.push({ check: "no_matching_reminder", missing: ["calendars.events"], reason: "Appointments could not be read." }); return { findings, setAside, cannot }; } const calendars = [...new Set(s.events.map((e) => e.calendarId))]; for (const cal of calendars) { if (!sourceOk(s, "calendars.notifications")) { cannot.push({ check: "no_matching_reminder", subjectId: cal, missing: ["calendars.notifications"], reason: "The calendar's reminder settings could not be read, so no reminder can be said to be expected.", }); continue; } const reminders = (s.notifications[cal] ?? []).filter( (n) => n.notificationType === "reminder" && n.isActive && !n.deleted && n.receiverType === "contact", ); if (reminders.length === 0) { findings.push({ kind: "no_native_reminder_configured", tier: "secondary", subjectId: cal, marks: [], detail: { calendarId: cal }, }); continue; } for (const e of s.events.filter((x) => x.calendarId === cal)) { for (const r of reminders) { for (const bt of r.beforeTime ?? []) { const offset = bt.timeOffset * UNIT_MIN[bt.unit]; const dueAt = addMinutes(e.startTime, -offset); if (t(dueAt) > t(now)) continue; // not due yet const base: Mark[] = [ { at: e.dateAdded, label: "Booked", role: "input" as const }, { at: dueAt, label: `Reminder due (${bt.timeOffset} ${bt.unit} before)`, role: "expected" as const }, { at: e.startTime, label: "Appointment", role: "input" as const }, ]; if (e.appointmentStatus === "cancelled") { if (t(e.dateUpdated) <= t(dueAt)) setAside.push({ reason: "appointment_cancelled", check: "no_matching_reminder", contactId: e.contactId, subjectId: e.id, marks: base, detail: {} }); else cannot.push({ check: "no_matching_reminder", contactId: e.contactId, subjectId: e.id, missing: [], reason: "Cancelled, but last changed after the reminder was due; check its status at that time." }); continue; } if (t(e.dateAdded) > t(dueAt)) { setAside.push({ reason: "late_booking", check: "no_matching_reminder", contactId: e.contactId, subjectId: e.id, marks: base, detail: {} }); continue; } const contact = s.contacts.find((c) => c.id === e.contactId); const ch = r.channel === "sms" ? "SMS" : r.channel === "email" ? "Email" : null; const need: SourceKey[] = ch === "Email" ? ["messages.email"] : ["messages.nonEmail"]; const tol = cfg.reminderMatchToleranceMinutes; const match = readable(s.messages, (x) => x.dateAdded, now).find( (m) => m.contactId === e.contactId && m.direction === "outbound" && channelOf(m) === ch && isSent(m) && Math.abs(t(m.dateAdded) - t(dueAt)) <= tol * MIN, ); if (match) continue; if (need.some((k) => !sourceOk(s, k))) { cannot.push({ check: "no_matching_reminder", contactId: e.contactId, subjectId: e.id, missing: need.filter((k) => !sourceOk(s, k)), reason: "The reminder's channel was not read completely." }); continue; } if (contact && !allowedChannels(contact).includes(ch ?? "SMS")) continue; findings.push({ kind: "no_matching_reminder", tier: "secondary", contactId: e.contactId, subjectId: e.id, marks: [...base, { at: now, label: "Data read", role: "check" as const }].sort((a, b) => t(a.at) - t(b.at)), detail: { channel: r.channel, offset: `${bt.timeOffset} ${bt.unit}`, recordChangedAfterDue: t(e.dateUpdated) > t(dueAt) }, }); } } } } return { findings, setAside, cannot }; } // ---------- secondary: frequency ---------- export function checkFrequency(s: Snapshot, cfg: CheckConfig) { const findings: Finding[] = []; const cannot: CannotConclude[] = []; const missing = (["messages.nonEmail", "messages.email"] as SourceKey[]).filter((k) => !sourceOk(s, k)); if (missing.length) cannot.push({ check: "frequency_to_check", missing, reason: "Messages were not read on every channel, so counts may be low." }); const byContact = new Map(); for (const m of s.messages) if (m.direction === "outbound" && m.source && AUTOMATED.has(m.source) && channelOf(m) !== null && isSent(m)) byContact.set(m.contactId, [...(byContact.get(m.contactId) ?? []), m]); for (const [contactId, list] of byContact) { const sorted = list.sort((a, b) => t(a.dateAdded) - t(b.dateAdded)); let best = 0, bestStart = 0; for (let i = 0; i < sorted.length; i++) { let j = i; while (j + 1 < sorted.length && t(sorted[j + 1].dateAdded) - t(sorted[i].dateAdded) <= 24 * 60 * MIN) j++; if (j - i + 1 > best) { best = j - i + 1; bestStart = i; } } if (best > cfg.maxAutomatedPer24h) findings.push({ kind: "frequency_to_check", tier: "secondary", contactId, subjectId: sorted[bestStart].id, marks: sorted.slice(bestStart, bestStart + best).map((m) => ({ at: m.dateAdded, label: `Automated ${channelOf(m)}`, role: "found" as const })), detail: { count: best, limit: cfg.maxAutomatedPer24h }, }); } return { findings, cannot }; } // ---------- secondary: expected workflows ---------- export function checkWorkflows(s: Snapshot, cfg: CheckConfig) { const findings: Finding[] = []; const cannot: CannotConclude[] = []; if (!sourceOk(s, "workflows")) { cannot.push({ check: "expected_workflow_not_active", missing: ["workflows"], reason: "The workflow list could not be read." }); return { findings, cannot }; } for (const exp of cfg.expectedActiveWorkflows) { const w = s.workflows.find((x) => x.name === exp.name); if (!w || w.status !== exp.expectedStatus) findings.push({ kind: "expected_workflow_not_active", tier: "secondary", subjectId: w?.id ?? exp.name, marks: w ? [{ at: w.updatedAt, label: "Last updated", role: "input" as const }] : [], detail: { name: exp.name, status: w?.status ?? "not found", expected: exp.expectedStatus }, }); } return { findings, cannot }; } // ---------- the run ---------- export function runCheck(s: Snapshot, cfg: CheckConfig, now: IsoDate): Report { const collectionProblems = assessCollection(s, cfg, now); const parts = [ checkFirstReply(s, cfg, now), checkPersonalization(s, cfg, now), checkContradiction(s, cfg, now), checkTimeInStage(s, cfg, now), checkReminders(s, cfg, now), checkFrequency(s, cfg), checkWorkflows(s, cfg), ]; const findings = parts.flatMap((p) => p.findings); const setAside: SetAside[] = parts.flatMap((p) => ("setAside" in p ? (p.setAside as SetAside[]) : [])); const cannotConclude: CannotConclude[] = parts.flatMap((p) => ("cannot" in p ? (p.cannot as CannotConclude[]) : [])); const stale = collectionProblems.find((p) => p.problem === "stale_run"); if (stale) { const kinds: FindingKind[] = ["no_qualifying_reply", "personalization_defect", "contradictory_followup", "time_in_stage", "no_matching_reminder", "frequency_to_check", "expected_workflow_not_active"]; for (const check of kinds) cannotConclude.push({ check, missing: [], reason: `${stale.note} Nothing newer is known, so nothing since then is concluded.` }); } const leads = new Set(findings.map((f) => f.contactId).filter(Boolean)); const incomplete = collectionProblems.length > 0 || cannotConclude.length > 0; return { checkedAt: now, collectedAt: s.collectedAt, collectionProblems, findings, setAside, cannotConclude, counts: { exceptions: findings.length, contactsConcerned: leads.size, contactsSetAside: new Set(setAside.map((x) => x.contactId)).size, cannotConclude: cannotConclude.length }, verdict: incomplete ? "incomplete" : findings.length ? "exceptions_found" : "nothing_to_report", }; } export type { CalendarEvent };