///
// 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;
}