/// // The SENDING project. Runs under the dedicated sending account, every 15 minutes. // Reads the hiring workbook (edited by the team), writes only the Arrival ID cell // of new rows, and appends to the journal workbook (not edited by the team). import { planTriggers } from "../core/triggers"; import { runPass as corePass, type PassReport } from "../core/pass"; import { parseDecisions, parseHiring } from "../core/schema"; import { HIRING_COLUMNS, type HiringSource, type Journal, type LetterTemplate, type MailSender, type PassConfig } from "../core/types"; import { appendJournal, authorizationStatus, headerColumn, installExactly, prop, readGrid, readJournal, tab, TABS } from "./common"; const MANAGED = ["runPass"]; function hiringBook() { return prop("HIRING_BOOK_ID"); } function opsBook() { return prop("OPS_BOOK_ID"); } /** The team edits the letter and the on/paused switch in the "Welcome letter" tab. */ export function readLetterSettings(): { letter: LetterTemplate; paused: boolean } { const rows = readGrid(tab(hiringBook(), TABS.letter)); const kv = new Map(rows.map((r) => [String(r[0]).trim().toLowerCase(), String(r[1] ?? "")])); const need = (k: string) => { const v = kv.get(k); if (!v) throw new Error(`"${k}" is empty in the Welcome letter tab`); return v; }; return { letter: { subject: need("subject"), body: need("body"), senderName: need("sender name"), replyTo: need("reply-to") }, paused: (kv.get("sending") ?? "").trim().toLowerCase() !== "on", }; } export function sheetSource(): HiringSource { return { readHiring: () => readGrid(tab(hiringBook(), TABS.hiring)), readDecisions: () => readGrid(tab(hiringBook(), TABS.decisions)), writeArrivalId(rowNumber, expected, id) { const sheet = tab(hiringBook(), TABS.hiring); const header = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map((h) => String(h).trim()); const col = (name: string) => headerColumn(header, name); if (!col(HIRING_COLUMNS.therapist) || !col(HIRING_COLUMNS.welcomeEmail) || !col(HIRING_COLUMNS.arrivalId)) return false; const row = sheet.getRange(rowNumber, 1, 1, header.length).getValues()[0].map(String); if (row[col(HIRING_COLUMNS.therapist) - 1].trim() !== expected.therapist) return false; if (row[col(HIRING_COLUMNS.welcomeEmail) - 1].trim() !== expected.welcomeEmail) return false; if (row[col(HIRING_COLUMNS.arrivalId) - 1].trim()) return false; sheet.getRange(rowNumber, col(HIRING_COLUMNS.arrivalId)).setValue(id); return true; }, }; } export function mailSender(): MailSender { return { send: (l) => MailApp.sendEmail({ to: l.to, subject: l.subject, body: l.body, name: l.senderName, replyTo: l.replyTo }), remainingQuota: () => MailApp.getRemainingDailyQuota(), }; } export function passConfig(): PassConfig { const { letter, paused } = readLetterSettings(); return { mode: prop("MODE", "dry-run") === "live" ? "live" : "dry-run", scopeFromStartDate: prop("SCOPE_FROM_START_DATE"), sendingPaused: paused, letter, }; } export function runPassNow(overrides: { source?: HiringSource; sender?: MailSender; journal?: Journal } = {}): PassReport { const lock = LockService.getScriptLock(); return corePass( { source: overrides.source ?? sheetSource(), sender: overrides.sender ?? mailSender(), journal: overrides.journal ?? sheetJournal(), clock: { now: () => new Date() }, lock: { tryAcquire: () => lock.tryLock(20000), release: () => { SpreadsheetApp.flush(); // pending writes are committed before another execution can start lock.releaseLock(); }, }, }, passConfig(), ); } export function sheetJournal(): Journal { return { read: () => readJournal(opsBook()), append: (e) => appendJournal(opsBook(), e) }; } export function install() { const every = Number(prop("PASS_EVERY_MINUTES", "15")); const desired = [{ handler: "runPass", kind: "every-minutes" as const, value: every }]; return installExactly(desired, (existing, installed) => planTriggers(existing, desired, MANAGED, undefined, installed)); } /** Run after every code change, as the operating account. Changes nothing. */ export function verify() { const out: Record = { authorization: authorizationStatus() }; try { const h = parseHiring(readGrid(tab(hiringBook(), TABS.hiring))); out.hiringSheet = h.ok ? `OK (${h.rows.length} rows)` : `MISSING COLUMNS: ${h.missing.join(", ")}`; const d = parseDecisions(readGrid(tab(hiringBook(), TABS.decisions))); out.decisionsTab = d.ok ? "OK" : `MISSING COLUMNS: ${d.missing.join(", ")}`; const s = readLetterSettings(); out.letter = `OK (${s.letter.subject})`; out.sending = s.paused ? "PAUSED" : "ON"; out.journal = `OK (${readJournal(opsBook()).length} events)`; } catch (e) { out.error = (e as Error).message; } out.mode = prop("MODE", "dry-run"); out.triggers = ScriptApp.getProjectTriggers().map((t) => t.getHandlerFunction()).join(", ") || "none"; out.mailQuotaLeft = String(MailApp.getRemainingDailyQuota()); return out; }