// The decision core. Pure, synchronous, no I/O: the same file is compiled for the // web page and bundled for Google Apps Script. Everything outside (the hiring // sheet, the mail service, the journal, the clock, the lock) comes in through // the interfaces below. export type Grid = string[][]; /** Column headers are found by NAME, never by position, so moving or sorting columns changes nothing. */ export const HIRING_COLUMNS = { arrivalId: "Arrival ID", therapist: "Therapist", status: "Status", startDate: "Start date", welcomeEmail: "Welcome email", approvedBy: "Approved by", } as const; export const DECISION_COLUMNS = { arrivalId: "Arrival ID", decision: "Decision", by: "By", } as const; export interface HiringRow { rowNumber: number; // 1-based sheet row, used only to write the Arrival ID back arrivalId: string; therapist: string; status: string; startDate: string; // YYYY-MM-DD welcomeEmail: string; approvedBy: string; } export type DecisionKind = "send-again" | "confirm-sent" | "approve-new-address"; export interface DecisionRow { arrivalId: string; decision: string; by: string; } export type Mode = "dry-run" | "live"; interface Base { at: string; // ISO timestamp passId: string; } export type JournalEvent = | (Base & { type: "pass"; outcome: "complete" | "failed"; mode: Mode; paused: boolean; rowsRead?: number; reason?: string; /** When the reading finished (the event's `at` is when it started). */ completedAt?: string; /** Rows with a name but no reference at the end of this reading. */ unregistered?: string[]; }) | (Base & { type: "id-assigned"; arrivalId: string; therapist: string; email: string }) | (Base & { type: "baseline"; arrivalId: string }) | (Base & { type: "approval"; arrivalId: string; address: string; approvedBy: string; therapist?: string; startDate?: string }) | (Base & { type: "approval-cleared"; arrivalId: string }) | (Base & { type: "would-send"; arrivalId: string; address: string }) | (Base & { type: "attempt"; arrivalId: string; address: string; letterVersion: string }) | (Base & { type: "sent"; arrivalId: string }) | (Base & { type: "send-error"; arrivalId: string; message: string }) | (Base & { type: "cancelled"; arrivalId: string; reason: "withdrawn" }) | (Base & { type: "decision"; arrivalId: string; decision: string; by: string; key: string; applied: boolean; }); export type JournalEventType = JournalEvent["type"]; // ---------- ports ---------- export interface HiringSource { /** Throws if the sheet cannot be read. */ readHiring(): Grid; /** The "Decisions" tab where the team answers a Needs review. Throws if unreadable. */ readDecisions(): Grid; /** Writes an Arrival ID into an empty cell, only if the row still holds the expected person. */ writeArrivalId(rowNumber: number, expected: { therapist: string; welcomeEmail: string }, id: string): boolean; } export interface OutgoingLetter { to: string; subject: string; body: string; senderName: string; replyTo: string; } export interface MailSender { /** Returns normally when the mail service accepted the message. That is not a delivery receipt. */ send(letter: OutgoingLetter): void; remainingQuota(): number; } export interface Journal { read(): JournalEvent[]; append(event: JournalEvent): void; } export interface Clock { now(): Date; } export interface PassLock { tryAcquire(): boolean; release(): void; } // ---------- configuration ---------- export interface LetterTemplate { subject: string; body: string; senderName: string; replyTo: string; } /** * Guards that can be switched off ONLY in tests, to prove that each test fails * when its guard is missing (the negative control). Production code never sets this. */ export type Guard = | "exclusive-pass" // one pass at a time (LockService in Apps Script) | "attempt-before-send" // write "attempt" to the journal before calling the mail service | "stable-arrival-id" // identity = Arrival ID, not row number | "arrival-not-person" // a rehire is a new arrival, not blocked by an old letter to the same address | "schema-check" // required columns are checked by name before anything else | "go-live-scope" // arrivals before the chosen start date are recorded, never sent | "approval-required" // nothing is sent without a team approval bound to the address | "status-normalized" // "hired " counts as Hired; "Offer accepted" does not | "withdrawal-cancels" // a withdrawal cancels what is still waiting | "possible-duplicate-hold" // same address + same start date under two IDs is held for review | "notify-after-accept" // an alert counts as notified only once the mail service accepted it | "count-unregistered" // rows without a reference are counted and reported, never dropped | "approval-snapshot" // approval binds name, address and start date; any change holds the letter | "reread-before-send" // the row is read again just before each send | "no-id-recycling" // a reference is never handed out twice, even after a partial write | "orphan-hold" // a row that matches an already-sent arrival whose reference was removed is held | "date-check" // a missing or impossible start date holds the letter | "signed-decisions" // a decision without a name in By, or two opposite decisions, is ignored | "heartbeat-check" // the monitor alerts when complete passes stop | "trigger-dedup"; // reinstalling does not duplicate triggers export interface PassConfig { mode: Mode; /** Arrivals starting before this date (YYYY-MM-DD) are history: recorded, never sent. */ scopeFromStartDate: string; sendingPaused: boolean; letter: LetterTemplate; unsafeDisabledGuards?: Guard[]; } export interface Contacts { technicalOwner: string; hiringCoordinator: string; } export interface CheckConfig { /** A routine that has not completed a pass for this long is reported as stopped. */ staleAfterMinutes: number; /** An approved arrival still unsent after this long is reported. */ pendingAfterMinutes: number; contacts: Contacts; unsafeDisabledGuards?: Guard[]; } export function guardOn(disabled: Guard[] | undefined, g: Guard): boolean { return !(disabled ?? []).includes(g); }