// Generated from src/core + src/apps-script by scripts/build-apps-script.mjs. Do not edit by hand. "use strict"; var WelcomeCheck = (() => { 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/monitor-entry.ts var monitor_entry_exports = {}; __export(monitor_entry_exports, { dailySummary: () => dailySummary, install: () => install, periodicCheck: () => periodicCheck, verify: () => verify }); // 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 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]; } // src/core/types.ts var HIRING_COLUMNS = { arrivalId: "Arrival ID", therapist: "Therapist", status: "Status", startDate: "Start date", welcomeEmail: "Welcome email", approvedBy: "Approved by" }; function guardOn(disabled, g) { return !(disabled != null ? disabled : []).includes(g); } // 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 }; } // src/core/monitor.ts var emptyCheckState = () => ({ firstSeen: {}, notified: {} }); function markNotified(state, keys, at) { const notified = { ...state.notified }; for (const k of keys) notified[k] = at; return { ...state, notified }; } var TECHNICAL = [ "journal-unreadable", "sheet-unreadable", "column-missing", "routine-stopped", "routine-failing", "pending-too-long", "row-not-registered", "uncertain-send", "send-error" ]; function runCheck(input, config2, now, state = emptyCheckState(), fmt = (s) => s) { var _a, _b, _c, _d, _e, _f; const dis = config2.unsafeDisabledGuards; const previousFirstSeen = state.firstSeen; let unregistered = []; const sentLog = []; const checkedAt = now.toISOString(); const { technicalOwner, hiringCoordinator } = config2.contacts; const alerts = []; const notes = []; let impossible = false; let events = null; let views = []; let lastCompletePassAt; 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.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 = 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.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.type === "pass"); const completes = passes.filter((p) => p.outcome === "complete"); const lastComplete = completes[completes.length - 1]; const doneAt = (p) => { var _a2; return (_a2 = p.completedAt) != null ? _a2 : p.at; }; lastCompletePassAt = lastComplete ? doneAt(lastComplete) : void 0; const names = /* @__PURE__ */ new Map(); for (const e of events) if (e.type === "id-assigned" || e.type === "approval" && e.therapist) names.set(e.arrivalId, e.therapist); for (const e of events) if (e.type === "sent") sentLog.push({ arrivalId: e.arrivalId, therapist: (_a = names.get(e.arrivalId)) != null ? _a : e.arrivalId, at: e.at }); if (guardOn(dis, "count-unregistered") && unregistered.length && completes.length >= 2) { const [a, b] = completes.slice(-2).map((p) => { var _a2; return new Set((_a2 = p.unregistered) != null ? _a2 : []); }); 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))) / 6e4 : Infinity; if (ageMin > config2.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) : void 0, 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: ${(_b = lastPass.reason) != null ? _b : "no reason recorded"}`, since: firstFail == null ? void 0 : firstFail.at, who: technicalOwner, action: "Fix what the message names; nothing is sent until a run completes, and nothing is lost either." }); } if (lastPass == null ? void 0 : lastPass.paused) notes.push("Sending is paused: letters wait until it is switched back on."); if ((lastPass == null ? void 0 : 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}: ${(_c = a.message) != null ? _c : "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: ${(_d = v.history.approval) == null ? void 0 : _d.therapist}, ${(_e = v.history.approval) == null ? void 0 : _e.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 ${(_f = v.history.approval) == null ? void 0 : _f.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)) / 6e4; if (ageMin > config2.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 = {}; const full = alerts.map((a) => { var _a2, _b2, _c2; const since = (_b2 = (_a2 = a.since) != null ? _a2 : previousFirstSeen[a.key]) != null ? _b2 : checkedAt; firstSeen[a.key] = (_c2 = previousFirstSeen[a.key]) != null ? _c2 : since; return { ...a, since }; }); const alreadyNotified = guardOn(dis, "notify-after-accept") ? state.notified : previousFirstSeen; const notified = {}; 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 }; } function renderAlertNote(a, checkedAt, fmt) { 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) { 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"; } } function composeMorningSummary(r, fmt) { const day = 24 * 3600 * 1e3; 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") }; } // 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/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 formatter(tz) { return (iso) => Utilities.formatDate(new Date(iso), tz, "EEE d MMM, h:mm a z"); } 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/monitor.ts var MANAGED = ["runCheck", "morningSummary"]; function config() { return { staleAfterMinutes: Number(prop("STALE_AFTER_MINUTES", "40")), pendingAfterMinutes: Number(prop("PENDING_AFTER_MINUTES", "60")), contacts: { technicalOwner: prop("TECHNICAL_OWNER"), hiringCoordinator: prop("HIRING_COORDINATOR") } }; } function loadState() { const raw = PropertiesService.getScriptProperties().getProperty("CHECK_STATE"); return raw ? JSON.parse(raw) : emptyCheckState(); } function saveState(st) { PropertiesService.getScriptProperties().setProperty("CHECK_STATE", JSON.stringify(st)); } function check(state) { const r = runCheck( { readHiring: () => readGrid(tab(prop("HIRING_BOOK_ID"), TABS.hiring)), readJournal: () => readJournal(prop("OPS_BOOK_ID")) }, config(), /* @__PURE__ */ new Date(), state, formatter(Session.getScriptTimeZone()) ); PropertiesService.getScriptProperties().setProperty( "LAST_CHECK", JSON.stringify({ at: r.checkedAt, status: r.status, alerts: r.alerts.length }) ); return r; } function withLock(f) { const lock = LockService.getScriptLock(); if (!lock.tryLock(2e4)) return { skipped: "another check is running" }; try { return f(); } finally { lock.releaseLock(); } } function periodicCheck() { return withLock(() => { const r = check(loadState()); let state = { firstSeen: r.firstSeen, notified: r.notified }; const fmt = formatter(Session.getScriptTimeZone()); const emailed = []; const failed = []; for (const a of r.toNotify) { const note = renderAlertNote(a, r.checkedAt, fmt); try { MailApp.sendEmail({ to: prop("ALERT_TO"), subject: note.subject, body: note.body }); state = markNotified(state, [a.key], (/* @__PURE__ */ new Date()).toISOString()); emailed.push(a.kind); } catch (e) { failed.push(`${a.kind}: ${e.message}`); } saveState(state); } saveState(state); return { status: r.status, alerts: r.alerts.map((a) => a.kind), emailed, failed }; }); } function dailySummary() { return withLock(() => { const r = check(loadState()); saveState({ firstSeen: r.firstSeen, notified: r.notified }); const s = composeMorningSummary(r, formatter(Session.getScriptTimeZone())); MailApp.sendEmail({ to: prop("SUMMARY_TO", prop("ALERT_TO")), subject: s.subject, body: s.body }); return { status: r.status }; }); } function install() { const desired = [ { handler: "runCheck", kind: "every-minutes", value: Number(prop("CHECK_EVERY_MINUTES", "15")) }, { handler: "morningSummary", kind: "daily-at-hour", value: Number(prop("SUMMARY_HOUR", "7")) } ]; return installExactly(desired, (existing, installed) => planTriggers(existing, desired, MANAGED, void 0, installed)); } function verify() { var _a; return { authorization: authorizationStatus(), triggers: ScriptApp.getProjectTriggers().map((t) => t.getHandlerFunction()).join(", ") || "none", lastCheck: (_a = PropertiesService.getScriptProperties().getProperty("LAST_CHECK")) != null ? _a : "never" }; } return __toCommonJS(monitor_entry_exports); })(); /** Trigger handler, every 15 minutes: emails each new alert once. */ function runCheck() { var r = WelcomeCheck.periodicCheck(); console.log(JSON.stringify(r)); return r; } /** Trigger handler, once a day. */ function morningSummary() { var r = WelcomeCheck.dailySummary(); console.log(JSON.stringify(r)); return r; } function installTriggers() { var r = WelcomeCheck.install(); console.log(JSON.stringify(r)); return r; } function verifyDeployment() { var r = WelcomeCheck.verify(); console.log(JSON.stringify(r, null, 2)); return r; }