import { deriveArrivals, foldHistories, sentAttempt, type ArrivalView, type Reason } from "./derive"; import { decisionKind, letterVersion, looksLikeAddress, normalizeAddress, renderLetter, statusKind } from "./rules"; import { parseDecisions, parseHiring } from "./schema"; import { guardOn, type Clock, type HiringRow, type HiringSource, type Journal, type JournalEvent, type MailSender, type PassConfig, type PassLock, } from "./types"; export interface PassDeps { source: HiringSource; sender: MailSender; journal: Journal; clock: Clock; lock: PassLock; newPassId?: () => string; } export interface PassReport { passId: string; at: string; outcome: "complete" | "failed" | "skipped"; reason?: string; sent: { arrivalId: string; to: string }[]; wouldSend: { arrivalId: string; to: string }[]; held: { arrivalId: string; why: Reason | "paused" | "quota" | "already-welcomed" | "changed-during-reading" }[]; /** Rows with a name but no reference after this reading. */ unregistered: string[]; cancelled: string[]; baselined: string[]; idsAssigned: { rowNumber: number; arrivalId: string }[]; views: ArrivalView[]; } const ymd = (d: Date) => d.toISOString().slice(0, 10).replace(/-/g, ""); /** * One pass of the sending routine. Safe to run as often as you like: every * decision is re-derived from the sheet and the journal, and nothing is sent * twice for the same arrival without a written team decision. */ export function runPass(deps: PassDeps, config: PassConfig): PassReport { const dis = config.unsafeDisabledGuards; const now = deps.clock.now(); const at = now.toISOString(); const passId = deps.newPassId?.() ?? `P-${at}-${Math.random().toString(36).slice(2, 7)}`; const report: PassReport = { passId, at, outcome: "complete", sent: [], wouldSend: [], held: [], cancelled: [], baselined: [], idsAssigned: [], views: [], unregistered: [], }; const exclusive = guardOn(dis, "exclusive-pass"); if (exclusive && !deps.lock.tryAcquire()) { report.outcome = "skipped"; report.reason = "Another pass is running."; return report; } try { inner(deps, config, report); } finally { if (exclusive) deps.lock.release(); } return report; } function inner(deps: PassDeps, config: PassConfig, report: PassReport) { const dis = config.unsafeDisabledGuards; const { passId, at } = report; const events: JournalEvent[] = [...deps.journal.read()]; const record = (e: JournalEvent) => { deps.journal.append(e); events.push(e); }; const fail = (reason: string) => { record({ type: "pass", at, passId, outcome: "failed", mode: config.mode, paused: config.sendingPaused, reason }); report.outcome = "failed"; report.reason = reason; }; // 1. Read the sheet and check its columns before anything else. let hiringGrid, decisionGrid; try { hiringGrid = deps.source.readHiring(); } catch (e) { return fail(`The hiring sheet could not be read (${(e as Error).message}).`); } const hiring = parseHiring(hiringGrid, guardOn(dis, "schema-check")); if (!hiring.ok) return fail(`The hiring sheet has no column named ${hiring.missing.map((m) => `"${m}"`).join(", ")}.`); try { decisionGrid = deps.source.readDecisions(); } catch (e) { return fail(`The Decisions tab could not be read (${(e as Error).message}).`); } const decisions = parseDecisions(decisionGrid); if (!decisions.ok) return fail(`The Decisions tab has no column named ${decisions.missing.map((m) => `"${m}"`).join(", ")}.`); const known = foldHistories(events); // what existed before this pass const rows = hiring.rows; // 2. Give each new row a stable Arrival ID (identity of the arrival, not of the row or the person). if (guardOn(dis, "stable-arrival-id")) { const noRecycling = guardOn(dis, "no-id-recycling"); const prefix = `A-${ymd(new Date(at))}-`; const seqOf = (id: string) => (id.startsWith(prefix) ? Number(id.slice(prefix.length)) || 0 : 0); // Never reuse a number: take the highest one seen in the journal AND in the sheet. let seq = noRecycling ? Math.max( 0, ...events.filter((e) => e.type === "id-assigned").map((e) => seqOf((e as { arrivalId: string }).arrivalId)), ...rows.map((r) => seqOf(r.arrivalId)), ) : events.filter((e) => e.type === "id-assigned").length; for (const r of rows) { if (r.arrivalId || !r.therapist) continue; const id = `${prefix}${String(++seq).padStart(3, "0")}`; const assigned: JournalEvent = { type: "id-assigned", at, passId, arrivalId: id, therapist: r.therapist, email: r.welcomeEmail }; // Journal first: if the cell write then fails, the number is spent, never handed out again. if (noRecycling) record(assigned); if (deps.source.writeArrivalId(r.rowNumber, { therapist: r.therapist, welcomeEmail: r.welcomeEmail }, id)) { r.arrivalId = id; if (!noRecycling) record(assigned); report.idsAssigned.push({ rowNumber: r.rowNumber, arrivalId: id }); } } } // 3. Go-live scope: arrivals seen for the first time with a start date before the chosen // date are history. They are recorded once and never sent. let views = deriveArrivals(rows, events, dis); if (guardOn(dis, "go-live-scope")) { for (const v of views) { const h = known.get(v.key); const firstSight = !h || (!h.approval && !h.attempts.length && !h.baselineAt && !h.wouldSend && !h.cancelledAt); if (firstSight && !h?.baselineAt && v.row.startDate && v.row.startDate < config.scopeFromStartDate) { record({ type: "baseline", at, passId, arrivalId: v.key }); report.baselined.push(v.key); } } views = deriveArrivals(rows, events, dis); } // 4. Team decisions (Decisions tab). Each row is read once; one that does not apply is recorded as ignored. const byKey = new Map(views.map((v) => [v.key, v])); const consumed = new Map(); for (const e of events) if (e.type === "decision") consumed.set(e.key, (consumed.get(e.key) ?? 0) + 1); const seen = new Map(); const signed = guardOn(dis, "signed-decisions"); // Two opposite answers for the same arrival in one reading cancel each other: nothing is applied. const kindsById = new Map>(); for (const d of decisions.rows) { const k = decisionKind(d.decision); if (!k) continue; const set = kindsById.get(d.arrivalId) ?? new Set(); set.add(k); kindsById.set(d.arrivalId, set); } for (const d of decisions.rows) { const key = `${d.arrivalId}|${d.decision.trim().toLowerCase()}|${d.by}`; const n = (seen.get(key) ?? 0) + 1; seen.set(key, n); if (n <= (consumed.get(key) ?? 0)) continue; const v = byKey.get(d.arrivalId); const kind = decisionKind(d.decision); let applied = false; if (v && (kind === "send-again" || kind === "confirm-sent")) applied = v.reason === "uncertain-send" || v.reason === "send-error"; if (v && kind === "approve-new-address") applied = v.reason === "address-changed" || v.reason === "approval-changed"; if (signed && !d.by.trim()) applied = false; if (signed && kindsById.get(d.arrivalId)?.has("send-again") && kindsById.get(d.arrivalId)?.has("confirm-sent")) applied = false; record({ type: "decision", at, passId, arrivalId: d.arrivalId, decision: kind ?? d.decision, by: d.by, key, applied }); if (applied && kind === "approve-new-address" && v) record({ type: "approval", at, passId, arrivalId: v.key, address: v.row.welcomeEmail, approvedBy: d.by, therapist: v.row.therapist, startDate: v.row.startDate, }); } views = deriveArrivals(rows, events, dis); // 5. Approvals: the initials in "Approved by" approve THIS arrival at THIS address. for (const v of views) { const h = v.history; if (v.sentAt) continue; if (v.row.approvedBy && !h.approval && v.status === "hired" && looksLikeAddress(v.row.welcomeEmail)) record({ type: "approval", at, passId, arrivalId: v.key, address: v.row.welcomeEmail, approvedBy: v.row.approvedBy, therapist: v.row.therapist, startDate: v.row.startDate, }); else if (!v.row.approvedBy && h.approval) record({ type: "approval-cleared", at, passId, arrivalId: v.key }); } views = deriveArrivals(rows, events, dis); // 6. Withdrawals cancel what is still waiting. What was already sent stays in the history. for (const v of views) { if (v.status === "withdrawn" && !v.sentAt && !v.history.cancelledAt && (v.history.approval || v.history.wouldSend)) { record({ type: "cancelled", at, passId, arrivalId: v.key, reason: "withdrawn" }); report.cancelled.push(v.key); } } views = deriveArrivals(rows, events, dis); // 7. Send what is ready. const allHistories = foldHistories(events); let quotaLeft: number | null = null; for (const v of views) { if (v.state !== "ready") { if (v.reason && v.state !== "sent") report.held.push({ arrivalId: v.key, why: v.reason }); continue; } const to = v.row.welcomeEmail.trim(); if (!guardOn(dis, "arrival-not-person")) { // Negative control only: deduplicating by person blocks a legitimate rehire. const already = [...allHistories.values()].some((h) => normalizeAddress(sentAttempt(h)?.address ?? "") === normalizeAddress(to)); if (already) { report.held.push({ arrivalId: v.key, why: "already-welcomed" }); continue; } } if (config.mode === "dry-run") { if (!v.history.wouldSend || normalizeAddress(v.history.wouldSend.address) !== normalizeAddress(to)) record({ type: "would-send", at, passId, arrivalId: v.key, address: to }); report.wouldSend.push({ arrivalId: v.key, to }); continue; } if (config.sendingPaused) { report.held.push({ arrivalId: v.key, why: "paused" }); continue; } if (quotaLeft === null) quotaLeft = deps.sender.remainingQuota(); if (quotaLeft <= 0) { // Stop cleanly before the attempt: nothing is recorded, the next pass resumes. report.held.push({ arrivalId: v.key, why: "quota" }); continue; } if (guardOn(dis, "reread-before-send") && !stillTheSame(deps, v.row, dis)) { // Someone changed this row while the reading was running: leave it for the next reading. report.held.push({ arrivalId: v.key, why: "changed-during-reading" }); continue; } const letter = renderLetter(config.letter, v.row); const version = letterVersion(config.letter); const attempt: JournalEvent = { type: "attempt", at, passId, arrivalId: v.key, address: to, letterVersion: version }; const beforeSend = guardOn(dis, "attempt-before-send"); if (beforeSend) record(attempt); try { deps.sender.send(letter); } catch (e) { if (!beforeSend) record(attempt); record({ type: "send-error", at, passId, arrivalId: v.key, message: (e as Error).message }); report.held.push({ arrivalId: v.key, why: "send-error" }); continue; } quotaLeft--; if (!beforeSend) record(attempt); record({ type: "sent", at, passId, arrivalId: v.key }); report.sent.push({ arrivalId: v.key, to }); } report.unregistered = guardOn(dis, "count-unregistered") ? rows.filter((r) => r.therapist && !r.arrivalId).map((r) => r.therapist) : []; record({ type: "pass", at, passId, outcome: "complete", mode: config.mode, paused: config.sendingPaused, rowsRead: rows.length, completedAt: deps.clock.now().toISOString(), unregistered: report.unregistered, }); report.views = deriveArrivals(rows, events, dis); } /** Reads the sheet again and checks the row still says what the reading saw. */ function stillTheSame(deps: PassDeps, row: HiringRow, dis: PassConfig["unsafeDisabledGuards"]): boolean { let grid; try { grid = deps.source.readHiring(); } catch { return false; } const parsed = parseHiring(grid, guardOn(dis, "schema-check")); if (!parsed.ok) return false; const now = parsed.rows.filter((r) => (row.arrivalId ? r.arrivalId === row.arrivalId : r.rowNumber === row.rowNumber)); return ( now.length > 0 && now.every( (r) => r.therapist === row.therapist && normalizeAddress(r.welcomeEmail) === normalizeAddress(row.welcomeEmail) && r.startDate === row.startDate && r.approvedBy === row.approvedBy && statusKind(r.status) === statusKind(row.status), ) ); }