/// // Apps Script adapters shared by the sending project and the checking project. // Only this layer talks to Google; the decisions live in src/core. import type { Grid, JournalEvent } from "../core/types"; export const TABS = { hiring: "Hiring", decisions: "Decisions", letter: "Welcome letter", journal: "Journal", } as const; export function prop(name: string, fallback?: string): string { const v = PropertiesService.getScriptProperties().getProperty(name); if (v === null || v === "") { if (fallback !== undefined) return fallback; throw new Error(`Script property ${name} is not set. See the setup notes.`); } return v; } export function tab(bookId: string, name: string): GoogleAppsScript.Spreadsheet.Sheet { const sheet = SpreadsheetApp.openById(bookId).getSheetByName(name); if (!sheet) throw new Error(`tab "${name}" not found`); return sheet; } /** Cell values as strings; dates as YYYY-MM-DD in the workbook's time zone. */ export function readGrid(sheet: GoogleAppsScript.Spreadsheet.Sheet): Grid { const tz = sheet.getParent().getSpreadsheetTimeZone(); const range = sheet.getDataRange(); return range.getValues().map((row) => row.map((c) => (c instanceof Date ? Utilities.formatDate(c, tz, "yyyy-MM-dd") : String(c))), ); } const JOURNAL_HEADER = ["At", "Type", "Arrival ID", "Event (JSON)"]; export function readJournal(opsBookId: string): JournalEvent[] { const sheet = tab(opsBookId, TABS.journal); const values = sheet.getDataRange().getValues(); if (!values.length || String(values[0][0]) !== JOURNAL_HEADER[0]) throw new Error("journal header missing"); return values .slice(1) .filter((r) => r[3]) .map((r) => JSON.parse(String(r[3])) as JournalEvent); } export function appendJournal(opsBookId: string, e: JournalEvent) { const sheet = tab(opsBookId, TABS.journal); const id = "arrivalId" in e ? e.arrivalId : ""; sheet.appendRow([e.at, e.type, id, JSON.stringify(e)]); // Commit the write now: Sheets may batch writes, and "attempt" must be stored BEFORE the mail call. // If this throws, the caller stops and no letter is sent. SpreadsheetApp.flush(); } /** Same header matching as the core parser (trimmed, case-insensitive). */ export function headerColumn(header: string[], name: string): number { return header.findIndex((h) => String(h).trim().toLowerCase() === name.trim().toLowerCase()) + 1; } export function ensureJournalHeader(sheet: GoogleAppsScript.Spreadsheet.Sheet) { if (sheet.getLastRow() === 0) sheet.appendRow(JOURNAL_HEADER); } export function formatter(tz: string) { return (iso: string) => Utilities.formatDate(new Date(iso), tz, "EEE d MMM, h:mm a z"); } export function installExactly( desired: { handler: string; kind: "every-minutes" | "daily-at-hour"; value: number }[], plan: ( existing: { id: string; handler: string }[], installed: typeof desired | undefined, ) => { create: typeof desired; remove: string[] }, ) { const props = PropertiesService.getScriptProperties(); const raw = props.getProperty("INSTALLED_TRIGGERS"); const triggers = ScriptApp.getProjectTriggers(); const p = plan( triggers.map((t) => ({ id: t.getUniqueId(), handler: t.getHandlerFunction() })), raw ? (JSON.parse(raw) as typeof desired) : undefined, ); for (const t of triggers) if (p.remove.includes(t.getUniqueId())) ScriptApp.deleteTrigger(t); for (const d of p.create) { const b = ScriptApp.newTrigger(d.handler).timeBased(); if (d.kind === "every-minutes") b.everyMinutes(d.value).create(); else b.everyDays(1).atHour(d.value).create(); } props.setProperty("INSTALLED_TRIGGERS", JSON.stringify(desired)); return { created: p.create.map((d) => d.handler), removed: p.remove.length, now: ScriptApp.getProjectTriggers().map((t) => t.getHandlerFunction()), }; } export function authorizationStatus(): string { const info = ScriptApp.getAuthorizationInfo(ScriptApp.AuthMode.FULL); return info.getAuthorizationStatus() === ScriptApp.AuthorizationStatus.REQUIRED ? `REQUIRED: open ${info.getAuthorizationUrl()} as the operating account` : "OK"; }