import { deriveArrivals, type ArrivalView, type Reason } from "./derive"; import { parseHiring } from "./schema"; import { guardOn, type CheckConfig, type Grid, type JournalEvent } from "./types"; /** * The check runs in ANOTHER Apps Script project, under ANOTHER account. It only * reads: the hiring sheet and the journal the sending routine writes. It never * sends a welcome letter and never writes to the hiring sheet. */ export type AlertKind = | "journal-unreadable" | "sheet-unreadable" | "column-missing" | "routine-stopped" | "routine-failing" | "pending-too-long" | "row-not-registered" | Reason; export interface Alert { key: string; kind: AlertKind; what: string; since: string; // ISO who: string; action: string; arrivalId?: string; } export interface CheckResult { checkedAt: string; status: "all-clear" | "attention" | "check-impossible"; lastCompletePassAt?: string; alerts: Alert[]; /** Open alerts whose note has not yet been ACCEPTED by the mail service: send these, then mark them. */ toNotify: Alert[]; /** Rows with a name but no reference in the sheet right now. */ unregistered: string[]; /** Sent events in the journal (kept even if the row was later deleted). */ sentLog: { arrivalId: string; therapist: string; at: string }[]; notes: string[]; /** Carry this to the next check so "since" survives between runs. */ firstSeen: Record; /** Notes accepted by the mail service, pruned to open alerts. Update it with markNotified. */ notified: Record; views: ArrivalView[]; } export interface CheckInput { readHiring: () => Grid; readJournal: () => JournalEvent[]; } export type Fmt = (iso: string) => string; export interface CheckState { firstSeen: Record; notified: Record; } export const emptyCheckState = (): CheckState => ({ firstSeen: {}, notified: {} }); /** Call only after the mail service accepted the note. A failed send leaves the alert to notify. */ export function markNotified(state: CheckState, keys: string[], at: string): CheckState { const notified = { ...state.notified }; for (const k of keys) notified[k] = at; return { ...state, notified }; } /** The part of each alert that is technical (the automation) rather than work waiting for a person. */ export const TECHNICAL: AlertKind[] = [ "journal-unreadable", "sheet-unreadable", "column-missing", "routine-stopped", "routine-failing", "pending-too-long", "row-not-registered", "uncertain-send", "send-error", ]; export function runCheck( input: CheckInput, config: CheckConfig, now: Date, state: CheckState = emptyCheckState(), fmt: Fmt = (s) => s, ): CheckResult { const dis = config.unsafeDisabledGuards; const previousFirstSeen = state.firstSeen; let unregistered: string[] = []; const sentLog: CheckResult["sentLog"] = []; const checkedAt = now.toISOString(); const { technicalOwner, hiringCoordinator } = config.contacts; const alerts: (Omit & { since?: string })[] = []; const notes: string[] = []; let impossible = false; let events: JournalEvent[] | null = null; let views: ArrivalView[] = []; let lastCompletePassAt: string | undefined; try { events = input.readJournal(); } catch (e) { impossible = true; alerts.push({ key: "journal-unreadable", kind: "journal-unreadable", what: `The check could not read the sending journal (${(e as Error).message}).`, who: technicalOwner, action: "Make sure the checking account still has view access to the journal workbook. Until then, nothing can be confirmed either way.", }); } let grid: Grid | null = null; try { grid = input.readHiring(); } catch (e) { impossible = true; alerts.push({ key: "sheet-unreadable", kind: "sheet-unreadable", what: `The check could not read the hiring sheet (${(e as Error).message}).`, who: technicalOwner, action: "Make sure the checking account still has view access to the hiring sheet. Until then, nothing can be confirmed either way.", }); } if (grid) { const parsed = parseHiring(grid, guardOn(dis, "schema-check")); if (!parsed.ok) { impossible = true; for (const col of parsed.missing) alerts.push({ key: `column-missing:${col}`, kind: "column-missing", what: `The hiring sheet no longer has a column named "${col}". No letter can go out while it is missing.`, who: hiringCoordinator, action: `Rename the column back to "${col}" (or undo the last change to the header row). The next run picks up where it stopped.`, }); } else { if (events) views = deriveArrivals(parsed.rows, events, dis); if (guardOn(dis, "count-unregistered")) unregistered = parsed.rows.filter((r) => r.therapist && !r.arrivalId).map((r) => r.therapist); } } if (events) { const passes = events.filter((e): e is Extract => e.type === "pass"); const completes = passes.filter((p) => p.outcome === "complete"); const lastComplete = completes[completes.length - 1]; const doneAt = (p: (typeof passes)[number]) => p.completedAt ?? p.at; lastCompletePassAt = lastComplete ? doneAt(lastComplete) : undefined; // Journal-based record of letters sent, independent of rows still present in the sheet. const names = new Map(); for (const e of events) if (e.type === "id-assigned" || (e.type === "approval" && e.therapist)) names.set(e.arrivalId, (e as { therapist: string }).therapist); for (const e of events) if (e.type === "sent") sentLog.push({ arrivalId: e.arrivalId, therapist: names.get(e.arrivalId) ?? e.arrivalId, at: e.at }); // A row still without a reference after two complete readings is reported, never dropped. if (guardOn(dis, "count-unregistered") && unregistered.length && completes.length >= 2) { const [a, b] = completes.slice(-2).map((p) => new Set(p.unregistered ?? [])); const stuck = unregistered.filter((n) => a.has(n) && b.has(n)); if (stuck.length) alerts.push({ key: `row-not-registered:${stuck.join(",")}`, kind: "row-not-registered", what: `${stuck.length} row${stuck.length > 1 ? "s" : ""} in the hiring sheet (${stuck.join(", ")}) still ha${stuck.length > 1 ? "ve" : "s"} no reference after two runs, so the automation is not handling ${stuck.length > 1 ? "them" : "it"}.`, who: technicalOwner, action: "Check that the sending account can still edit the hiring sheet and that the Arrival ID column is not protected against it. Nothing is sent for these rows until they have a reference.", }); } if (unregistered.length) notes.push(`${unregistered.length} row${unregistered.length > 1 ? "s have" : " has"} no reference yet (${unregistered.join(", ")}); the next run normally gives one.`); const lastPass = passes[passes.length - 1]; if (guardOn(dis, "heartbeat-check")) { const ageMin = lastComplete ? (now.getTime() - Date.parse(doneAt(lastComplete))) / 60000 : Infinity; if (ageMin > config.staleAfterMinutes) alerts.push({ key: "routine-stopped", kind: "routine-stopped", what: lastComplete ? `The sending automation has not gone through the hiring sheet since ${fmt(doneAt(lastComplete))}.` : "The sending automation has never gone through the hiring sheet.", since: lastComplete ? doneAt(lastComplete) : undefined, who: technicalOwner, action: "Open the sending project in Apps Script and look at its triggers (the schedule that starts it) and its executions. If the schedule is gone, run installTriggers once: it recreates it, and running it twice does not create a second one. Approved letters are not lost: they go out the next time it runs completely.", }); } if (lastPass && lastPass.outcome === "failed") { const firstFail = passes.find((p) => p.outcome === "failed" && (!lastComplete || p.at > lastComplete.at)); alerts.push({ key: "routine-failing", kind: "routine-failing", what: `The sending routine runs but stops before sending: ${lastPass.reason ?? "no reason recorded"}`, since: firstFail?.at, who: technicalOwner, action: "Fix what the message names; nothing is sent until a run completes, and nothing is lost either.", }); } if (lastPass?.paused) notes.push("Sending is paused: letters wait until it is switched back on."); if (lastPass?.mode === "dry-run") notes.push("Dry run: the routine records what it would send and sends nothing."); const livePasses = lastPass && lastPass.mode === "live" && !lastPass.paused; for (const v of views) { const name = v.row.therapist; const id = v.key; const base = { arrivalId: id, since: v.since }; switch (v.reason) { case "uncertain-send": { const a = v.history.attempts[v.history.attempts.length - 1]; alerts.push({ ...base, key: `${id}:uncertain-send`, kind: "uncertain-send", what: `The welcome letter to ${name} may or may not have gone out: the send started at ${fmt(a.at)} and its result was never recorded. No further send is attempted until someone reviews what happened.`, who: technicalOwner, action: `Look in the sending account's Sent folder for a letter to ${a.address} around ${fmt(a.at)}. If it is there, write "Confirm sent" for ${id} in the Decisions tab, with your name in By. If it is not, you may write "Send again", knowing the first letter may still have gone out.`, }); break; } case "send-error": { const a = v.history.attempts[v.history.attempts.length - 1]; alerts.push({ ...base, key: `${id}:send-error`, kind: "send-error", what: `The mail service refused the welcome letter to ${name}: ${a.message ?? "no message"}.`, who: technicalOwner, action: `Check the address and the sending account, then write "Send again" for ${id} in the Decisions tab.`, }); break; } case "approval-changed": alerts.push({ ...base, key: `${id}:approval-changed`, kind: "approval-changed", what: `The name or start date of ${name} changed after the welcome was approved (approved: ${v.history.approval?.therapist}, ${v.history.approval?.startDate}). Nothing is sent.`, who: hiringCoordinator, action: `If the new details are right, write "Approve new address" for ${id} in the Decisions tab with your name in By; it approves the row as it is now.`, }); break; case "invalid-date": alerts.push({ ...base, key: `${id}:invalid-date`, kind: "invalid-date", what: `${name}'s start date "${v.row.startDate}" is missing or not a real date. Nothing is sent.`, who: hiringCoordinator, action: "Correct the Start date cell (YYYY-MM-DD).", }); break; case "reference-removed": alerts.push({ ...base, key: `${id}:reference-removed`, kind: "reference-removed", what: `${name}'s row looks like an arrival already welcomed (same address and start date) whose reference was removed from the sheet. No second letter is sent.`, who: hiringCoordinator, action: "Put the original reference back in the Arrival ID cell, or delete the extra row. If this really is a new arrival, give it its own start date.", }); break; case "address-changed": alerts.push({ ...base, key: `${id}:address-changed`, kind: "address-changed", what: `${name}'s welcome address changed from ${v.history.approval?.address} to ${v.row.welcomeEmail} after it was approved. Nothing is sent to either address.`, who: hiringCoordinator, action: `If ${v.row.welcomeEmail} is right, write "Approve new address" for ${id} in the Decisions tab. If not, put the approved address back.`, }); break; case "invalid-address": alerts.push({ ...base, key: `${id}:invalid-address`, kind: "invalid-address", what: `${name}'s welcome address "${v.row.welcomeEmail}" is not a usable email address.`, who: hiringCoordinator, action: "Correct the Welcome email cell. The letter goes out once the corrected address is approved.", }); break; case "conflicting-copies": alerts.push({ ...base, key: `${id}:conflicting-copies`, kind: "conflicting-copies", what: `${v.copies} rows carry the same arrival reference (${id}) with different details.${v.sentAt ? ` The letter already went to ${v.sentTo}; nothing more is sent.` : " Nothing is sent until they agree."}`, who: hiringCoordinator, action: "Keep the right row and delete the other copy. A copy should never be used to enter a new person.", }); break; case "possible-duplicate": alerts.push({ ...base, key: `${id}:possible-duplicate`, kind: "possible-duplicate", what: `${name} appears twice with the same address and the same start date, under two arrival references.`, who: hiringCoordinator, action: "Delete the extra row. If these really are two arrivals, correct the address or the start date of one of them.", }); break; case "withdrawn-after-sent": notes.push(`${name} withdrew after the welcome letter went out on ${fmt(v.sentAt!)}. The history is kept; there is nothing to undo.`); break; } if (v.state === "ready" && livePasses && v.history.approval) { const ageMin = (now.getTime() - Date.parse(v.history.approval.at)) / 60000; if (ageMin > config.pendingAfterMinutes) alerts.push({ ...base, key: `${id}:pending-too-long`, kind: "pending-too-long", what: `${name} was approved at ${fmt(v.history.approval.at)} and the letter has still not gone out.`, since: v.history.approval.at, who: technicalOwner, action: "Look at the last executions of the sending project: a daily mail limit or an error will be named there.", }); } } } const firstSeen: Record = {}; const full: Alert[] = alerts.map((a) => { const since = a.since ?? previousFirstSeen[a.key] ?? checkedAt; firstSeen[a.key] = previousFirstSeen[a.key] ?? since; return { ...a, since }; }); // Negative control only: treating "first seen" as "notified" loses an alert whose first note failed. const alreadyNotified = guardOn(dis, "notify-after-accept") ? state.notified : previousFirstSeen; const notified: Record = {}; for (const a of full) if (state.notified[a.key]) notified[a.key] = state.notified[a.key]; return { checkedAt, status: impossible ? "check-impossible" : full.length || unregistered.length ? "attention" : "all-clear", lastCompletePassAt, alerts: full, toNotify: full.filter((a) => !(a.key in alreadyNotified)), unregistered, sentLog, notes, firstSeen, notified, views, }; } // ---------- what people receive ---------- export function renderAlertNote(a: Alert, checkedAt: string, fmt: Fmt): { subject: string; body: string } { return { subject: `Welcome letters: ${shortWhat(a)}`, body: [ a.what, "", `Since: ${fmt(a.since)}`, `Who: ${a.who}`, `What to do: ${a.action}`, "", `Checked at ${fmt(checkedAt)}. This note is sent once it is accepted by the mail service; the problem stays in the morning summary until it is resolved.`, ].join("\n"), }; } function shortWhat(a: Alert): string { switch (a.kind) { case "routine-stopped": return "the sending routine has stopped"; case "routine-failing": return "the sending routine cannot finish"; case "column-missing": return "a column is missing in the hiring sheet"; case "sheet-unreadable": case "journal-unreadable": return "the check cannot read its sources"; case "uncertain-send": return "one letter needs a quick check"; default: return "one arrival needs a look"; } } export function composeMorningSummary(r: CheckResult, fmt: Fmt): { subject: string; body: string } { const day = 24 * 3600 * 1000; const since = Date.parse(r.checkedAt) - day; const sent = r.sentLog.filter((x) => Date.parse(x.at) >= since); const waiting = r.views.filter((v) => v.state === "waiting-address"); const technical = r.alerts.filter((a) => TECHNICAL.includes(a.kind)); const forPeople = r.alerts.filter((a) => !TECHNICAL.includes(a.kind)); const automation = r.status === "check-impossible" ? "The check could not be completed, so nothing below can be confirmed." : technical.length ? `The automation needs attention: ${technical.length} item${technical.length > 1 ? "s" : ""} below.` : "The automation is running normally."; const todo = waiting.length + forPeople.length; const team = todo ? `For your team: ${todo} thing${todo > 1 ? "s" : ""} to do (listed below).` : "For your team: nothing to do today."; const lines = [ automation, team, "", `Last complete run: ${r.lastCompletePassAt ? fmt(r.lastCompletePassAt) : "none"}`, `Letters sent in the last 24 hours: ${sent.length ? sent.map((x) => x.therapist).join(", ") : "none"}`, `Waiting for an approved address: ${waiting.length ? waiting.map((v) => v.row.therapist).join(", ") : "none"}`, ]; if (r.alerts.length) { lines.push("", "Needs a person:"); for (const a of r.alerts) lines.push(`- ${a.what} (${a.who}) ${a.action}`); } if (r.notes.length) lines.push("", ...r.notes); return { subject: `Welcome letters, morning summary`, body: lines.join("\n") }; }