// Generated from src/core + src/apps-script by scripts/build-apps-script.mjs. Do not edit by hand. "use strict"; var WelcomeSender = (() => { var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/apps-script/sender-demo-entry.ts var sender_demo_entry_exports = {}; __export(sender_demo_entry_exports, { createDemoWorkbooks: () => createDemoWorkbooks, deleteSendingTrigger: () => deleteSendingTrigger, install: () => install, runPassNow: () => runPassNow, setupTests: () => setupTests, verify: () => verify }); // src/core/types.ts var HIRING_COLUMNS = { arrivalId: "Arrival ID", therapist: "Therapist", status: "Status", startDate: "Start date", welcomeEmail: "Welcome email", approvedBy: "Approved by" }; var DECISION_COLUMNS = { arrivalId: "Arrival ID", decision: "Decision", by: "By" }; function guardOn(disabled, g) { return !(disabled != null ? disabled : []).includes(g); } // src/core/triggers.ts function planTriggers(existing, desired, managedHandlers, disabled, installed) { if (!guardOn(disabled, "trigger-dedup")) return { create: [...desired], remove: [] }; const create = []; const remove = []; for (const d of desired) { const mine = existing.filter((t) => t.handler === d.handler); const was = installed == null ? void 0 : installed.find((i) => i.handler === d.handler); const changed = !!was && (was.kind !== d.kind || was.value !== d.value); if (!mine.length) create.push(d); else if (changed) { remove.push(...mine.map((t) => t.id)); create.push(d); } else remove.push(...mine.slice(1).map((t) => t.id)); } for (const t of existing) if (managedHandlers.includes(t.handler) && !desired.some((d) => d.handler === t.handler)) remove.push(t.id); return { create, remove }; } // src/core/rules.ts function statusKind(raw, normalized = true) { const s = normalized ? raw.trim().replace(/\s+/g, " ").toLowerCase() : raw; if (s === (normalized ? "hired" : "Hired")) return "hired"; if (s === (normalized ? "withdrawn" : "Withdrawn")) return "withdrawn"; return "other"; } function normalizeAddress(a) { return a.trim().toLowerCase(); } function looksLikeAddress(a) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a.trim()); } function decisionKind(raw) { const s = raw.trim().toLowerCase(); if (s === "send again") return "send-again"; if (s === "confirm sent") return "confirm-sent"; if (s === "approve new address") return "approve-new-address"; return null; } var MONTHS = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ]; var DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; function longDate(iso) { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); if (!m) return iso; const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])); return `${DAYS[d.getUTCDay()]}, ${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`; } function isRealDate(iso) { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso.trim()); if (!m) return false; const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])); return d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3]; } function firstName(fullName) { var _a; return (_a = fullName.trim().split(/\s+/)[0]) != null ? _a : ""; } function letterVersion(t) { let h = 5381; const s = `${t.subject}\0${t.body}`; for (let i = 0; i < s.length; i++) h = (h << 5) + h + s.charCodeAt(i) >>> 0; return `v-${h.toString(36)}`; } function renderLetter(t, a) { const fill = (s) => s.replace(/\{\{\s*first_name\s*\}\}/g, firstName(a.therapist)).replace(/\{\{\s*start_date\s*\}\}/g, longDate(a.startDate)); return { to: a.welcomeEmail.trim(), subject: fill(t.subject), body: fill(t.body), senderName: t.senderName, replyTo: t.replyTo }; } // src/core/derive.ts function foldHistories(events) { const map = /* @__PURE__ */ new Map(); const get = (id) => { let h = map.get(id); if (!h) map.set(id, h = { attempts: [] }); return h; }; for (const e of events) { if (e.type === "pass") continue; const h = get(e.arrivalId); if (!h.firstSeen) h.firstSeen = e.at; const last = h.attempts[h.attempts.length - 1]; switch (e.type) { case "baseline": h.baselineAt = e.at; break; case "approval": h.approval = { address: e.address, approvedBy: e.approvedBy, at: e.at, therapist: e.therapist, startDate: e.startDate }; break; case "approval-cleared": h.approval = void 0; break; case "would-send": h.wouldSend = { address: e.address, at: e.at }; break; case "attempt": h.attempts.push({ passId: e.passId, at: e.at, address: e.address, letterVersion: e.letterVersion }); break; case "sent": case "send-error": { const a = [...h.attempts].reverse().find((x) => x.passId === e.passId && !x.outcome); if (a) { a.outcome = e.type === "sent" ? "sent" : "error"; a.outcomeAt = e.at; if (e.type === "send-error") a.message = e.message; } break; } case "cancelled": h.cancelledAt = e.at; break; case "decision": if (e.applied && last && !last.released && (e.decision === "send-again" || e.decision === "confirm-sent")) { last.released = { by: e.by, at: e.at, decision: e.decision }; } break; } } return map; } function sentAttempt(h) { if (!h) return void 0; return h.attempts.find((a) => { var _a; return a.outcome === "sent" || ((_a = a.released) == null ? void 0 : _a.decision) === "confirm-sent"; }); } function uncertainAttempt(h) { const last = h == null ? void 0 : h.attempts[h.attempts.length - 1]; return last && !last.outcome && !last.released ? last : void 0; } function erroredAttempt(h) { const last = h == null ? void 0 : h.attempts[h.attempts.length - 1]; return last && last.outcome === "error" && !last.released ? last : void 0; } function arrivalKey(row, disabled) { return guardOn(disabled, "stable-arrival-id") ? row.arrivalId : `row-${row.rowNumber}`; } var sameContent = (a, b) => a.therapist === b.therapist && normalizeAddress(a.welcomeEmail) === normalizeAddress(b.welcomeEmail) && statusKind(a.status) === statusKind(b.status) && a.startDate === b.startDate && a.approvedBy === b.approvedBy; function deriveArrivals(rows, events, disabled) { var _a, _b, _c, _d, _e, _f; const histories = foldHistories(events); const groups = /* @__PURE__ */ new Map(); for (const r of rows) { const key = arrivalKey(r, disabled); if (!key) continue; const g = (_a = groups.get(key)) != null ? _a : []; g.push(r); groups.set(key, g); } const views = []; for (const [key, group] of groups) { group.sort((a, b) => a.rowNumber - b.rowNumber); const row = group[0]; const h = (_b = histories.get(key)) != null ? _b : { attempts: [] }; const raw = statusKind(row.status, guardOn(disabled, "status-normalized")); const status = raw === "withdrawn" && !guardOn(disabled, "withdrawal-cancels") && h.approval ? "hired" : raw; const v = { key, row, copies: group.length, status, state: "not-hired", history: h }; const sent = sentAttempt(h); if (sent) { v.sentAt = (_e = (_d = sent.outcomeAt) != null ? _d : (_c = sent.released) == null ? void 0 : _c.at) != null ? _e : sent.at; v.sentTo = sent.address; } const conflicting = group.some((r) => !sameContent(r, row)); const uncertain = uncertainAttempt(h); const errored = erroredAttempt(h); const address = row.welcomeEmail; if (conflicting) set(v, "needs-review", "conflicting-copies"); else if (h.baselineAt && !sent) set(v, "before-go-live", void 0, h.baselineAt); else if (sent) { if (status === "withdrawn") set(v, "withdrawn", "withdrawn-after-sent", v.sentAt); else set(v, "sent", void 0, v.sentAt); } else if (uncertain) set(v, "needs-review", "uncertain-send", uncertain.at); else if (errored) set(v, "needs-review", "send-error", errored.outcomeAt); else if (status === "withdrawn") set(v, "withdrawn", h.cancelledAt ? "cancelled" : void 0, h.cancelledAt); else if (status !== "hired") set(v, "not-hired", void 0, h.firstSeen); else if (guardOn(disabled, "date-check") && !isRealDate(row.startDate)) set(v, "needs-review", "invalid-date", h.firstSeen); else if (!address) set(v, "waiting-address", "no-address", h.firstSeen); else if (!looksLikeAddress(address)) set(v, "needs-review", "invalid-address", h.firstSeen); else if (!row.approvedBy && guardOn(disabled, "approval-required")) set(v, "waiting-address", "not-approved", h.firstSeen); else if (guardOn(disabled, "approval-required") && h.approval && normalizeAddress(h.approval.address) !== normalizeAddress(address)) set(v, "needs-review", "address-changed", h.approval.at); else if (guardOn(disabled, "approval-required") && guardOn(disabled, "approval-snapshot") && h.approval && (h.approval.therapist !== void 0 && h.approval.therapist !== row.therapist || h.approval.startDate !== void 0 && h.approval.startDate !== row.startDate)) set(v, "needs-review", "approval-changed", h.approval.at); else set(v, "ready", void 0, (_f = h.approval) == null ? void 0 : _f.at); views.push(v); } if (guardOn(disabled, "possible-duplicate-hold")) { const live = views.filter((v) => v.status !== "withdrawn" && v.row.welcomeEmail); for (const v of views) { if (v.state !== "ready" && v.state !== "waiting-address") continue; const twin = live.find( (o) => o !== v && o.state !== "before-go-live" && normalizeAddress(o.row.welcomeEmail) === normalizeAddress(v.row.welcomeEmail) && o.row.startDate === v.row.startDate ); if (twin) set(v, "needs-review", "possible-duplicate", v.since); } } if (guardOn(disabled, "orphan-hold")) { const present = new Set(views.map((v) => v.key)); const orphans = [...histories.entries()].filter(([k, h]) => !present.has(k) && sentAttempt(h)).map(([k, h]) => { var _a2; return { key: k, address: normalizeAddress(sentAttempt(h).address), startDate: (_a2 = h.approval) == null ? void 0 : _a2.startDate }; }); for (const v of views) { if (v.sentAt || v.state !== "ready" && v.state !== "waiting-address") continue; const o = orphans.find( (x) => x.address === normalizeAddress(v.row.welcomeEmail) && x.startDate !== void 0 && x.startDate === v.row.startDate ); if (o) set(v, "needs-review", "reference-removed", v.since); } } return views.sort((a, b) => a.row.rowNumber - b.row.rowNumber); } function set(v, state, reason, since) { v.state = state; v.reason = reason; v.since = since; } // src/core/schema.ts var norm = (s) => String(s != null ? s : "").trim().toLowerCase(); function headerIndex(header, names, lenient) { const idx = {}; const missing = []; const keys = Object.keys(names); keys.forEach((key) => { const at = header.findIndex((h) => norm(h) === norm(names[key])); if (at >= 0) idx[key] = at; else if (lenient) idx[key] = -1; else missing.push(names[key]); }); return { idx, missing }; } function parseHiring(grid, schemaCheck = true) { if (!grid.length) return { ok: false, missing: Object.values(HIRING_COLUMNS) }; const { idx, missing } = headerIndex(grid[0], HIRING_COLUMNS, !schemaCheck); if (missing.length) return { ok: false, missing }; const cell = (r, k) => { var _a; return String((_a = r[idx[k]]) != null ? _a : "").trim(); }; const rows = []; grid.slice(1).forEach((r, i) => { var _a; if (r.every((c) => String(c != null ? c : "").trim() === "")) return; rows.push({ rowNumber: i + 2, arrivalId: cell(r, "arrivalId"), therapist: cell(r, "therapist"), status: String((_a = r[idx.status]) != null ? _a : ""), // kept raw: normalisation is a rule, see rules.ts startDate: cell(r, "startDate"), welcomeEmail: cell(r, "welcomeEmail"), approvedBy: cell(r, "approvedBy") }); }); return { ok: true, rows }; } function parseDecisions(grid) { var _a, _b, _c; if (!grid.length) return { ok: false, missing: Object.values(DECISION_COLUMNS) }; const { idx, missing } = headerIndex(grid[0], DECISION_COLUMNS, false); if (missing.length) return { ok: false, missing }; const rows = []; for (const r of grid.slice(1)) { const arrivalId = String((_a = r[idx.arrivalId]) != null ? _a : "").trim(); const decision = String((_b = r[idx.decision]) != null ? _b : "").trim(); if (!arrivalId && !decision) continue; rows.push({ arrivalId, decision, by: String((_c = r[idx.by]) != null ? _c : "").trim() }); } return { ok: true, rows }; } // src/core/pass.ts var ymd = (d) => d.toISOString().slice(0, 10).replace(/-/g, ""); function runPass(deps, config) { var _a, _b; const dis = config.unsafeDisabledGuards; const now = deps.clock.now(); const at = now.toISOString(); const passId = (_b = (_a = deps.newPassId) == null ? void 0 : _a.call(deps)) != null ? _b : `P-${at}-${Math.random().toString(36).slice(2, 7)}`; const report = { passId, at, outcome: "complete", sent: [], wouldSend: [], held: [], cancelled: [], baselined: [], idsAssigned: [], views: [], unregistered: [] }; const exclusive = guardOn(dis, "exclusive-pass"); if (exclusive && !deps.lock.tryAcquire()) { report.outcome = "skipped"; report.reason = "Another pass is running."; return report; } try { inner(deps, config, report); } finally { if (exclusive) deps.lock.release(); } return report; } function inner(deps, config, report) { var _a, _b, _c, _d, _e, _f; const dis = config.unsafeDisabledGuards; const { passId, at } = report; const events = [...deps.journal.read()]; const record = (e) => { deps.journal.append(e); events.push(e); }; const fail = (reason) => { record({ type: "pass", at, passId, outcome: "failed", mode: config.mode, paused: config.sendingPaused, reason }); report.outcome = "failed"; report.reason = reason; }; let hiringGrid, decisionGrid; try { hiringGrid = deps.source.readHiring(); } catch (e) { return fail(`The hiring sheet could not be read (${e.message}).`); } const hiring = parseHiring(hiringGrid, guardOn(dis, "schema-check")); if (!hiring.ok) return fail(`The hiring sheet has no column named ${hiring.missing.map((m) => `"${m}"`).join(", ")}.`); try { decisionGrid = deps.source.readDecisions(); } catch (e) { return fail(`The Decisions tab could not be read (${e.message}).`); } const decisions = parseDecisions(decisionGrid); if (!decisions.ok) return fail(`The Decisions tab has no column named ${decisions.missing.map((m) => `"${m}"`).join(", ")}.`); const known = foldHistories(events); const rows = hiring.rows; if (guardOn(dis, "stable-arrival-id")) { const noRecycling = guardOn(dis, "no-id-recycling"); const prefix = `A-${ymd(new Date(at))}-`; const seqOf = (id) => id.startsWith(prefix) ? Number(id.slice(prefix.length)) || 0 : 0; let seq = noRecycling ? Math.max( 0, ...events.filter((e) => e.type === "id-assigned").map((e) => seqOf(e.arrivalId)), ...rows.map((r) => seqOf(r.arrivalId)) ) : events.filter((e) => e.type === "id-assigned").length; for (const r of rows) { if (r.arrivalId || !r.therapist) continue; const id = `${prefix}${String(++seq).padStart(3, "0")}`; const assigned = { type: "id-assigned", at, passId, arrivalId: id, therapist: r.therapist, email: r.welcomeEmail }; if (noRecycling) record(assigned); if (deps.source.writeArrivalId(r.rowNumber, { therapist: r.therapist, welcomeEmail: r.welcomeEmail }, id)) { r.arrivalId = id; if (!noRecycling) record(assigned); report.idsAssigned.push({ rowNumber: r.rowNumber, arrivalId: id }); } } } let views = deriveArrivals(rows, events, dis); if (guardOn(dis, "go-live-scope")) { for (const v of views) { const h = known.get(v.key); const firstSight = !h || !h.approval && !h.attempts.length && !h.baselineAt && !h.wouldSend && !h.cancelledAt; if (firstSight && !(h == null ? void 0 : h.baselineAt) && v.row.startDate && v.row.startDate < config.scopeFromStartDate) { record({ type: "baseline", at, passId, arrivalId: v.key }); report.baselined.push(v.key); } } views = deriveArrivals(rows, events, dis); } const byKey = new Map(views.map((v) => [v.key, v])); const consumed = /* @__PURE__ */ new Map(); for (const e of events) if (e.type === "decision") consumed.set(e.key, ((_a = consumed.get(e.key)) != null ? _a : 0) + 1); const seen = /* @__PURE__ */ new Map(); const signed = guardOn(dis, "signed-decisions"); const kindsById = /* @__PURE__ */ new Map(); for (const d of decisions.rows) { const k = decisionKind(d.decision); if (!k) continue; const set2 = (_b = kindsById.get(d.arrivalId)) != null ? _b : /* @__PURE__ */ new Set(); set2.add(k); kindsById.set(d.arrivalId, set2); } for (const d of decisions.rows) { const key = `${d.arrivalId}|${d.decision.trim().toLowerCase()}|${d.by}`; const n = ((_c = seen.get(key)) != null ? _c : 0) + 1; seen.set(key, n); if (n <= ((_d = consumed.get(key)) != null ? _d : 0)) continue; const v = byKey.get(d.arrivalId); const kind = decisionKind(d.decision); let applied = false; if (v && (kind === "send-again" || kind === "confirm-sent")) applied = v.reason === "uncertain-send" || v.reason === "send-error"; if (v && kind === "approve-new-address") applied = v.reason === "address-changed" || v.reason === "approval-changed"; if (signed && !d.by.trim()) applied = false; if (signed && ((_e = kindsById.get(d.arrivalId)) == null ? void 0 : _e.has("send-again")) && ((_f = kindsById.get(d.arrivalId)) == null ? void 0 : _f.has("confirm-sent"))) applied = false; record({ type: "decision", at, passId, arrivalId: d.arrivalId, decision: kind != null ? kind : d.decision, by: d.by, key, applied }); if (applied && kind === "approve-new-address" && v) record({ type: "approval", at, passId, arrivalId: v.key, address: v.row.welcomeEmail, approvedBy: d.by, therapist: v.row.therapist, startDate: v.row.startDate }); } views = deriveArrivals(rows, events, dis); for (const v of views) { const h = v.history; if (v.sentAt) continue; if (v.row.approvedBy && !h.approval && v.status === "hired" && looksLikeAddress(v.row.welcomeEmail)) record({ type: "approval", at, passId, arrivalId: v.key, address: v.row.welcomeEmail, approvedBy: v.row.approvedBy, therapist: v.row.therapist, startDate: v.row.startDate }); else if (!v.row.approvedBy && h.approval) record({ type: "approval-cleared", at, passId, arrivalId: v.key }); } views = deriveArrivals(rows, events, dis); for (const v of views) { if (v.status === "withdrawn" && !v.sentAt && !v.history.cancelledAt && (v.history.approval || v.history.wouldSend)) { record({ type: "cancelled", at, passId, arrivalId: v.key, reason: "withdrawn" }); report.cancelled.push(v.key); } } views = deriveArrivals(rows, events, dis); const allHistories = foldHistories(events); let quotaLeft = null; for (const v of views) { if (v.state !== "ready") { if (v.reason && v.state !== "sent") report.held.push({ arrivalId: v.key, why: v.reason }); continue; } const to = v.row.welcomeEmail.trim(); if (!guardOn(dis, "arrival-not-person")) { const already = [...allHistories.values()].some((h) => { var _a2, _b2; return normalizeAddress((_b2 = (_a2 = sentAttempt(h)) == null ? void 0 : _a2.address) != null ? _b2 : "") === normalizeAddress(to); }); if (already) { report.held.push({ arrivalId: v.key, why: "already-welcomed" }); continue; } } if (config.mode === "dry-run") { if (!v.history.wouldSend || normalizeAddress(v.history.wouldSend.address) !== normalizeAddress(to)) record({ type: "would-send", at, passId, arrivalId: v.key, address: to }); report.wouldSend.push({ arrivalId: v.key, to }); continue; } if (config.sendingPaused) { report.held.push({ arrivalId: v.key, why: "paused" }); continue; } if (quotaLeft === null) quotaLeft = deps.sender.remainingQuota(); if (quotaLeft <= 0) { report.held.push({ arrivalId: v.key, why: "quota" }); continue; } if (guardOn(dis, "reread-before-send") && !stillTheSame(deps, v.row, dis)) { report.held.push({ arrivalId: v.key, why: "changed-during-reading" }); continue; } const letter = renderLetter(config.letter, v.row); const version = letterVersion(config.letter); const attempt = { type: "attempt", at, passId, arrivalId: v.key, address: to, letterVersion: version }; const beforeSend = guardOn(dis, "attempt-before-send"); if (beforeSend) record(attempt); try { deps.sender.send(letter); } catch (e) { if (!beforeSend) record(attempt); record({ type: "send-error", at, passId, arrivalId: v.key, message: e.message }); report.held.push({ arrivalId: v.key, why: "send-error" }); continue; } quotaLeft--; if (!beforeSend) record(attempt); record({ type: "sent", at, passId, arrivalId: v.key }); report.sent.push({ arrivalId: v.key, to }); } report.unregistered = guardOn(dis, "count-unregistered") ? rows.filter((r) => r.therapist && !r.arrivalId).map((r) => r.therapist) : []; record({ type: "pass", at, passId, outcome: "complete", mode: config.mode, paused: config.sendingPaused, rowsRead: rows.length, completedAt: deps.clock.now().toISOString(), unregistered: report.unregistered }); report.views = deriveArrivals(rows, events, dis); } function stillTheSame(deps, row, dis) { let grid; try { grid = deps.source.readHiring(); } catch { return false; } const parsed = parseHiring(grid, guardOn(dis, "schema-check")); if (!parsed.ok) return false; const now = parsed.rows.filter((r) => row.arrivalId ? r.arrivalId === row.arrivalId : r.rowNumber === row.rowNumber); return now.length > 0 && now.every( (r) => r.therapist === row.therapist && normalizeAddress(r.welcomeEmail) === normalizeAddress(row.welcomeEmail) && r.startDate === row.startDate && r.approvedBy === row.approvedBy && statusKind(r.status) === statusKind(row.status) ); } // src/apps-script/common.ts var TABS = { hiring: "Hiring", decisions: "Decisions", letter: "Welcome letter", journal: "Journal" }; function prop(name, fallback) { const v = PropertiesService.getScriptProperties().getProperty(name); if (v === null || v === "") { if (fallback !== void 0) return fallback; throw new Error(`Script property ${name} is not set. See the setup notes.`); } return v; } function tab(bookId, name) { const sheet = SpreadsheetApp.openById(bookId).getSheetByName(name); if (!sheet) throw new Error(`tab "${name}" not found`); return sheet; } function readGrid(sheet) { 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)) ); } var JOURNAL_HEADER = ["At", "Type", "Arrival ID", "Event (JSON)"]; function readJournal(opsBookId) { 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]))); } function appendJournal(opsBookId, e) { const sheet = tab(opsBookId, TABS.journal); const id = "arrivalId" in e ? e.arrivalId : ""; sheet.appendRow([e.at, e.type, id, JSON.stringify(e)]); SpreadsheetApp.flush(); } function headerColumn(header, name) { return header.findIndex((h) => String(h).trim().toLowerCase() === name.trim().toLowerCase()) + 1; } function ensureJournalHeader(sheet) { if (sheet.getLastRow() === 0) sheet.appendRow(JOURNAL_HEADER); } function installExactly(desired, plan) { 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) : void 0 ); 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()) }; } function authorizationStatus() { const info = ScriptApp.getAuthorizationInfo(ScriptApp.AuthMode.FULL); return info.getAuthorizationStatus() === ScriptApp.AuthorizationStatus.REQUIRED ? `REQUIRED: open ${info.getAuthorizationUrl()} as the operating account` : "OK"; } // src/apps-script/sender.ts var MANAGED = ["runPass"]; function hiringBook() { return prop("HIRING_BOOK_ID"); } function opsBook() { return prop("OPS_BOOK_ID"); } function readLetterSettings() { var _a; const rows = readGrid(tab(hiringBook(), TABS.letter)); const kv = new Map(rows.map((r) => { var _a2; return [String(r[0]).trim().toLowerCase(), String((_a2 = r[1]) != null ? _a2 : "")]; })); const need = (k) => { 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: ((_a = kv.get("sending")) != null ? _a : "").trim().toLowerCase() !== "on" }; } function sheetSource() { 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) => 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; } }; } function mailSender() { return { send: (l) => MailApp.sendEmail({ to: l.to, subject: l.subject, body: l.body, name: l.senderName, replyTo: l.replyTo }), remainingQuota: () => MailApp.getRemainingDailyQuota() }; } function passConfig() { const { letter, paused } = readLetterSettings(); return { mode: prop("MODE", "dry-run") === "live" ? "live" : "dry-run", scopeFromStartDate: prop("SCOPE_FROM_START_DATE"), sendingPaused: paused, letter }; } function runPassNow(overrides = {}) { var _a, _b, _c; const lock = LockService.getScriptLock(); return runPass( { source: (_a = overrides.source) != null ? _a : sheetSource(), sender: (_b = overrides.sender) != null ? _b : mailSender(), journal: (_c = overrides.journal) != null ? _c : sheetJournal(), clock: { now: () => /* @__PURE__ */ new Date() }, lock: { tryAcquire: () => lock.tryLock(2e4), release: () => { SpreadsheetApp.flush(); lock.releaseLock(); } } }, passConfig() ); } function sheetJournal() { return { read: () => readJournal(opsBook()), append: (e) => appendJournal(opsBook(), e) }; } function install() { const every = Number(prop("PASS_EVERY_MINUTES", "15")); const desired = [{ handler: "runPass", kind: "every-minutes", value: every }]; return installExactly(desired, (existing, installed) => planTriggers(existing, desired, MANAGED, void 0, installed)); } function verify() { const out = { 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.message; } out.mode = prop("MODE", "dry-run"); out.triggers = ScriptApp.getProjectTriggers().map((t) => t.getHandlerFunction()).join(", ") || "none"; out.mailQuotaLeft = String(MailApp.getRemainingDailyQuota()); return out; } // src/core/letter-template.ts var EXAMPLE_LETTER = { senderName: "Mentella Health", replyTo: "a team inbox someone reads (your choice)", subject: "Welcome to Mentella Health, {{first_name}}", body: [ "Dear {{first_name}},", "", "Welcome to Mentella Health. We are very glad you are joining us, and we are looking forward to your start on {{start_date}}.", "", "[Next step, written by your team: who will be in touch, and about what.]", "", "If you have a question before then, simply reply to this email. [Who reads the replies, written by your team.]", "", "Warmly,", "[Name], for the Mentella Health team" ].join("\n") }; // src/apps-script/setup-tests.ts function self() { const email = Session.getEffectiveUser().getEmail(); const [local, domain] = email.split("@"); return { email, plus: (tag) => `${local}+${tag}@${domain}` }; } function assertOnlyOwnInbox() { var _a; const { email } = self(); const [local, domain] = email.split("@"); const grid = readGrid(tab(prop("HIRING_BOOK_ID"), TABS.hiring)); const col = grid[0].indexOf(HIRING_COLUMNS.welcomeEmail); for (const r of grid.slice(1)) { const a = String((_a = r[col]) != null ? _a : "").trim(); if (a && !(a.startsWith(`${local}+`) && a.endsWith(`@${domain}`)) && a !== email) throw new Error(`Refusing to run: ${a} is not a test inbox of ${email}`); } } function createDemoWorkbooks() { const { plus } = self(); const hiring = SpreadsheetApp.create("Welcome check demo: hiring sheet (fictional staff)"); const h = hiring.getSheets()[0].setName(TABS.hiring); h.appendRow(Object.values(HIRING_COLUMNS)); const rows = [ ["", "Past Therapist One", "Hired", "2025-03-03", plus("past1"), "KR"], ["", "Past Therapist Two", "Hired", "2025-06-16", plus("past2"), "KR"], ["", "Past Therapist Three", "hired ", "2026-02-02", plus("past3"), "KR"], ["", "Maya Okafor", "Hired", "2026-10-05", plus("maya"), "KR"], ["", "Daniel Reyes", "Hired", "2026-10-12", "", ""], ["", "Tom Beaulieu", "Offer accepted", "2026-10-19", plus("tom"), ""] ]; rows.forEach((r) => h.appendRow(r)); h.getRange(2, 4, rows.length, 1).setNumberFormat("yyyy-mm-dd"); hiring.insertSheet(TABS.decisions).appendRow(["Arrival ID", "Decision", "By"]); const l = hiring.insertSheet(TABS.letter); [ ["Subject", EXAMPLE_LETTER.subject], ["Body", EXAMPLE_LETTER.body], ["Sender name", "Welcome check demo (fictional)"], ["Reply-to", self().email], ["Sending", "On"] ].forEach((r) => l.appendRow(r)); const ops = SpreadsheetApp.create("Welcome check demo: journal"); const j = ops.getSheets()[0].setName(TABS.journal); ensureJournalHeader(j); const props = PropertiesService.getScriptProperties(); props.setProperties({ HIRING_BOOK_ID: hiring.getId(), OPS_BOOK_ID: ops.getId(), MODE: "dry-run", SCOPE_FROM_START_DATE: "2026-09-01", PASS_EVERY_MINUTES: "1" }); return { hiring: hiring.getUrl(), journal: ops.getUrl() }; } function setupTests() { var _a; assertOnlyOwnInbox(); const props = PropertiesService.getScriptProperties(); const log = []; const say = (s2) => { log.push(s2); Logger.log(s2); }; const sheet = () => tab(prop("HIRING_BOOK_ID"), TABS.hiring); const quota0 = MailApp.getRemainingDailyQuota(); props.setProperty("MODE", "dry-run"); let r = runPassNow(); say(`T1 dry-run first read: outcome=${r.outcome} sent=${r.sent.length} baselined=${r.baselined.length} wouldSend=${r.wouldSend.map((x) => x.to).join(",")} ids=${r.idsAssigned.length}`); props.setProperty("MODE", "live"); r = runPassNow(); say(`T2 live pass: outcome=${r.outcome} sent=${r.sent.map((x) => x.to).join(",")} quotaUsed=${quota0 - MailApp.getRemainingDailyQuota()}`); const s = sheet(); const header = s.getRange(1, 1, 1, s.getLastColumn()).getValues()[0].map(String); const nameCol = header.indexOf(HIRING_COLUMNS.therapist) + 1; const names = s.getRange(2, nameCol, s.getLastRow() - 1, 1).getValues().map((x) => String(x[0])); const mayaRow = names.indexOf("Maya Okafor") + 2; s.getRange(mayaRow, 1, 1, header.length).copyTo(s.getRange(s.getLastRow() + 1, 1)); s.getRange(2, 1, s.getLastRow() - 1, header.length).sort(nameCol); r = runPassNow(); say(`T3 copy + sort: outcome=${r.outcome} sent=${r.sent.length} mayaCopies=${(_a = r.views.find((v) => v.row.therapist === "Maya Okafor")) == null ? void 0 : _a.copies}`); s.appendRow(["", "Grace Lindqvist", "Hired", "2026-10-26", self().plus("grace"), "KR"]); const real = sheetJournal(); try { runPassNow({ journal: { read: real.read, append: (e) => { if (e.type === "sent") throw new Error("simulated stop after the send, before the journal write"); real.append(e); } } }); say("T4 interrupted send: NO EXCEPTION (unexpected)"); } catch (e) { say(`T4 interrupted send: stopped as simulated (${e.message})`); } r = runPassNow(); const grace = r.views.find((v) => v.row.therapist === "Grace Lindqvist"); say(`T4 next pass: sent=${r.sent.length} grace=${grace == null ? void 0 : grace.state}/${grace == null ? void 0 : grace.reason}`); const emailCol = header.indexOf(HIRING_COLUMNS.welcomeEmail) + 1; s.getRange(1, emailCol).setValue("Email (personal)"); r = runPassNow(); say(`T5 missing column: outcome=${r.outcome} reason=${r.reason} sent=${r.sent.length}`); s.getRange(1, emailCol).setValue(HIRING_COLUMNS.welcomeEmail); const a = install(); const b = install(); say(`T6 install twice: first created=${a.created.join(",") || "none"}; second created=${b.created.join(",") || "none"}; triggers now=${b.now.join(",")}`); say(`Mail quota used by this run: ${quota0 - MailApp.getRemainingDailyQuota()}`); props.setProperty("SETUP_TEST_LOG", JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), log })); return log; } function deleteSendingTrigger() { const t = ScriptApp.getProjectTriggers().filter((x) => x.getHandlerFunction() === "runPass"); t.forEach((x) => ScriptApp.deleteTrigger(x)); return `deleted ${t.length} trigger(s) at ${(/* @__PURE__ */ new Date()).toISOString()}`; } return __toCommonJS(sender_demo_entry_exports); })(); function runPass() { var r = WelcomeSender.runPassNow(); console.log(r.outcome + " sent=" + r.sent.length + " held=" + r.held.length + (r.reason ? " reason=" + r.reason : "")); } function installTriggers() { var r = WelcomeSender.install(); console.log(JSON.stringify(r)); return r; } function verifyDeployment() { var r = WelcomeSender.verify(); console.log(JSON.stringify(r, null, 2)); return r; } /** DEMO ONLY: creates the two fictional workbooks on the account running it. */ function createDemoWorkbooks() { var r = WelcomeSender.createDemoWorkbooks(); console.log(JSON.stringify(r)); return r; } /** DEMO ONLY: the documented setup tests; sends only to plus-addresses of this account. */ function setupTests() { var r = WelcomeSender.setupTests(); console.log(r.join("\n")); return r; } /** DEMO ONLY: deletes the sending trigger, to test that the check notices. */ function deleteSendingTrigger() { var r = WelcomeSender.deleteSendingTrigger(); console.log(r); return r; }