import { isRealDate, looksLikeAddress, normalizeAddress, statusKind, type StatusKind } from "./rules"; import { guardOn, type Guard, type HiringRow, type JournalEvent } from "./types"; // ---------- history: what the journal says happened to one arrival ---------- export interface Attempt { passId: string; at: string; address: string; letterVersion: string; outcome?: "sent" | "error"; outcomeAt?: string; message?: string; released?: { by: string; at: string; decision: "send-again" | "confirm-sent" }; } export interface History { firstSeen?: string; baselineAt?: string; approval?: { address: string; approvedBy: string; at: string; therapist?: string; startDate?: string }; wouldSend?: { address: string; at: string }; attempts: Attempt[]; cancelledAt?: string; } export function foldHistories(events: JournalEvent[]): Map { const map = new Map(); const get = (id: string) => { let h = map.get(id); if (!h) map.set(id, (h = { attempts: [] })); return h; }; for (const e of events) { if (e.type === "pass") continue; const h = get(e.arrivalId); if (!h.firstSeen) h.firstSeen = e.at; const last = h.attempts[h.attempts.length - 1]; switch (e.type) { case "baseline": h.baselineAt = e.at; break; case "approval": h.approval = { address: e.address, approvedBy: e.approvedBy, at: e.at, therapist: e.therapist, startDate: e.startDate }; break; case "approval-cleared": h.approval = undefined; break; case "would-send": h.wouldSend = { address: e.address, at: e.at }; break; case "attempt": h.attempts.push({ passId: e.passId, at: e.at, address: e.address, letterVersion: e.letterVersion }); break; case "sent": case "send-error": { const a = [...h.attempts].reverse().find((x) => x.passId === e.passId && !x.outcome); if (a) { a.outcome = e.type === "sent" ? "sent" : "error"; a.outcomeAt = e.at; if (e.type === "send-error") a.message = e.message; } break; } case "cancelled": h.cancelledAt = e.at; break; case "decision": if (e.applied && last && !last.released && (e.decision === "send-again" || e.decision === "confirm-sent")) { last.released = { by: e.by, at: e.at, decision: e.decision }; } break; } } return map; } export function sentAttempt(h: History | undefined): Attempt | undefined { if (!h) return undefined; return h.attempts.find((a) => a.outcome === "sent" || a.released?.decision === "confirm-sent"); } /** The last attempt started but never recorded as finished, and not yet answered by the team. */ export function uncertainAttempt(h: History | undefined): Attempt | undefined { const last = h?.attempts[h.attempts.length - 1]; return last && !last.outcome && !last.released ? last : undefined; } export function erroredAttempt(h: History | undefined): Attempt | undefined { const last = h?.attempts[h.attempts.length - 1]; return last && last.outcome === "error" && !last.released ? last : undefined; } // ---------- the view of each arrival ---------- export type ArrivalState = | "sent" | "ready" | "waiting-address" | "needs-review" | "not-hired" | "withdrawn" | "before-go-live"; export type Reason = | "no-address" | "not-approved" | "address-changed" | "approval-changed" | "invalid-date" | "reference-removed" | "invalid-address" | "uncertain-send" | "send-error" | "conflicting-copies" | "possible-duplicate" | "withdrawn-after-sent" | "cancelled"; export interface ArrivalView { key: string; row: HiringRow; copies: number; status: StatusKind; state: ArrivalState; reason?: Reason; since?: string; sentAt?: string; sentTo?: string; history: History; } export function arrivalKey(row: HiringRow, disabled?: Guard[]): string { return guardOn(disabled, "stable-arrival-id") ? row.arrivalId : `row-${row.rowNumber}`; } const sameContent = (a: HiringRow, b: HiringRow) => a.therapist === b.therapist && normalizeAddress(a.welcomeEmail) === normalizeAddress(b.welcomeEmail) && statusKind(a.status) === statusKind(b.status) && a.startDate === b.startDate && a.approvedBy === b.approvedBy; /** * Groups rows into arrivals and decides the state of each, from the sheet as it * is now and the journal of what already happened. Rows without an Arrival ID * are left out: the pass gives them one first. */ export function deriveArrivals(rows: HiringRow[], events: JournalEvent[], disabled?: Guard[]): ArrivalView[] { const histories = foldHistories(events); const groups = new Map(); for (const r of rows) { const key = arrivalKey(r, disabled); if (!key) continue; const g = groups.get(key) ?? []; g.push(r); groups.set(key, g); } const views: ArrivalView[] = []; for (const [key, group] of groups) { group.sort((a, b) => a.rowNumber - b.rowNumber); const row = group[0]; const h = histories.get(key) ?? { attempts: [] }; const raw = statusKind(row.status, guardOn(disabled, "status-normalized")); // Negative control only: without the guard, an approved arrival that withdrew is still treated as hired. const status = raw === "withdrawn" && !guardOn(disabled, "withdrawal-cancels") && h.approval ? "hired" : raw; const v: ArrivalView = { key, row, copies: group.length, status, state: "not-hired", history: h }; const sent = sentAttempt(h); if (sent) { v.sentAt = sent.outcomeAt ?? sent.released?.at ?? sent.at; v.sentTo = sent.address; } const conflicting = group.some((r) => !sameContent(r, row)); const uncertain = uncertainAttempt(h); const errored = erroredAttempt(h); const address = row.welcomeEmail; if (conflicting) set(v, "needs-review", "conflicting-copies"); else if (h.baselineAt && !sent) set(v, "before-go-live", undefined, h.baselineAt); else if (sent) { if (status === "withdrawn") set(v, "withdrawn", "withdrawn-after-sent", v.sentAt); else set(v, "sent", undefined, v.sentAt); } else if (uncertain) set(v, "needs-review", "uncertain-send", uncertain.at); else if (errored) set(v, "needs-review", "send-error", errored.outcomeAt); else if (status === "withdrawn") set(v, "withdrawn", h.cancelledAt ? "cancelled" : undefined, h.cancelledAt); else if (status !== "hired") set(v, "not-hired", undefined, h.firstSeen); else if (guardOn(disabled, "date-check") && !isRealDate(row.startDate)) set(v, "needs-review", "invalid-date", h.firstSeen); else if (!address) set(v, "waiting-address", "no-address", h.firstSeen); else if (!looksLikeAddress(address)) set(v, "needs-review", "invalid-address", h.firstSeen); else if (!row.approvedBy && guardOn(disabled, "approval-required")) set(v, "waiting-address", "not-approved", h.firstSeen); else if ( guardOn(disabled, "approval-required") && h.approval && normalizeAddress(h.approval.address) !== normalizeAddress(address) ) set(v, "needs-review", "address-changed", h.approval.at); else if ( guardOn(disabled, "approval-required") && guardOn(disabled, "approval-snapshot") && h.approval && ((h.approval.therapist !== undefined && h.approval.therapist !== row.therapist) || (h.approval.startDate !== undefined && h.approval.startDate !== row.startDate)) ) set(v, "needs-review", "approval-changed", h.approval.at); else set(v, "ready", undefined, h.approval?.at); views.push(v); } // Two different arrivals for the same address and the same start date look like one // person entered twice. Hold the one(s) not yet sent until someone looks. if (guardOn(disabled, "possible-duplicate-hold")) { const live = views.filter((v) => v.status !== "withdrawn" && v.row.welcomeEmail); for (const v of views) { if (v.state !== "ready" && v.state !== "waiting-address") continue; const twin = live.find( (o) => o !== v && o.state !== "before-go-live" && normalizeAddress(o.row.welcomeEmail) === normalizeAddress(v.row.welcomeEmail) && o.row.startDate === v.row.startDate, ); if (twin) set(v, "needs-review", "possible-duplicate", v.since); } } // A row that matches an arrival already welcomed (same address, same start date) whose reference is no // longer in the sheet: someone probably cleared the reference. Hold it rather than send a second letter. // A rehire has a new start date and is not held. if (guardOn(disabled, "orphan-hold")) { const present = new Set(views.map((v) => v.key)); const orphans = [...histories.entries()] .filter(([k, h]) => !present.has(k) && sentAttempt(h)) .map(([k, h]) => ({ key: k, address: normalizeAddress(sentAttempt(h)!.address), startDate: h.approval?.startDate })); for (const v of views) { if (v.sentAt || (v.state !== "ready" && v.state !== "waiting-address")) continue; const o = orphans.find( (x) => x.address === normalizeAddress(v.row.welcomeEmail) && x.startDate !== undefined && x.startDate === v.row.startDate, ); if (o) set(v, "needs-review", "reference-removed", v.since); } } return views.sort((a, b) => a.row.rowNumber - b.row.rowNumber); } function set(v: ArrivalView, state: ArrivalState, reason?: Reason, since?: string) { v.state = state; v.reason = reason; v.since = since; }