@reddoorla/maintenance 0.82.0 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/airtable-5ZPU3MT4.js +9 -0
- package/dist/{announce-7QHQGJPV.js → announce-4H64L2Z6.js} +4 -4
- package/dist/{blux-EATHOHKK.js → blux-ZGDSHI3U.js} +5 -5
- package/dist/{bump-deps-ZREAMK7M.js → bump-deps-2OL6PGTM.js} +5 -5
- package/dist/{chunk-OIDDJLS7.js → chunk-2WN4SNBQ.js} +2 -2
- package/dist/{chunk-HVOOCK6L.js → chunk-4DHQCN5H.js} +2 -1
- package/dist/{chunk-5E4L7WKE.js → chunk-5P2HVNLZ.js} +3 -3
- package/dist/{chunk-ZXRRKKMR.js → chunk-6V7565O2.js} +8 -8
- package/dist/{chunk-YRM6EED7.js → chunk-CLWNAMUX.js} +28 -2
- package/dist/chunk-CLWNAMUX.js.map +1 -0
- package/dist/{chunk-VXXF4Y25.js → chunk-FSRJAF2Q.js} +60 -3
- package/dist/chunk-FSRJAF2Q.js.map +1 -0
- package/dist/{chunk-B5ZUQZFL.js → chunk-ICXV25N7.js} +2 -2
- package/dist/{chunk-UH5YPYS4.js → chunk-JVWJJ36K.js} +5 -4
- package/dist/chunk-JVWJJ36K.js.map +1 -0
- package/dist/{chunk-4PW6ISX7.js → chunk-KV7DIRRO.js} +3 -3
- package/dist/{chunk-ODC4PXZL.js → chunk-MQUOUVQP.js} +2 -2
- package/dist/{chunk-LMCO7CYQ.js → chunk-OQFOMKBP.js} +2 -2
- package/dist/{chunk-FFI7P74K.js → chunk-PBCGXXG6.js} +2 -2
- package/dist/{chunk-T4FQ6TZT.js → chunk-QRDB4NLB.js} +6 -6
- package/dist/{chunk-K5JZ5KLO.js → chunk-R4AB6GCT.js} +3 -3
- package/dist/chunk-R53VL3RI.js +32 -0
- package/dist/chunk-R53VL3RI.js.map +1 -0
- package/dist/{chunk-FX7RAMGC.js → chunk-R6FPFMOS.js} +3 -3
- package/dist/{chunk-MGJ72BA4.js → chunk-RJB3QUSP.js} +2 -2
- package/dist/{chunk-ATJ6ZA2V.js → chunk-SDUJIANJ.js} +2 -2
- package/dist/{chunk-7ADQL5Y3.js → chunk-THEFTZ7F.js} +2 -2
- package/dist/{chunk-NT4WIP7I.js → chunk-UYCAF77E.js} +2 -2
- package/dist/chunk-VNB5XBQ4.js +699 -0
- package/dist/chunk-VNB5XBQ4.js.map +1 -0
- package/dist/{chunk-YAMRVXOD.js → chunk-VRWUL4TR.js} +2 -2
- package/dist/{chunk-2BHSGLWR.js → chunk-XDCRVYFL.js} +3 -3
- package/dist/chunk-XDCRVYFL.js.map +1 -0
- package/dist/{chunk-4NAOR44V.js → chunk-ZMIPG6TR.js} +2 -2
- package/dist/cli/bin.js +51 -23
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +1 -1
- package/dist/cli/commands/audit.js +10 -10
- package/dist/{convert-to-pnpm-UT2R6EOW.js → convert-to-pnpm-KCXOYOHW.js} +5 -5
- package/dist/{digest-CLFUWW62.js → digest-WGEPAWMP.js} +12 -7
- package/dist/digest-WGEPAWMP.js.map +1 -0
- package/dist/{ensure-site-LLPOXLWP.js → ensure-site-HYAIVZY2.js} +2 -2
- package/dist/{forms-notify-target-PN6P3WPW.js → forms-notify-target-HSL4ZMT5.js} +2 -2
- package/dist/{github-signals-HTH7474O.js → github-signals-I7XACMCE.js} +6 -6
- package/dist/{header-image-WMHZFU7B.js → header-image-ADYPTGVY.js} +2 -2
- package/dist/{health-endpoint-4E53ZMLE.js → health-endpoint-JNCUILHO.js} +6 -6
- package/dist/index.d.ts +65 -3
- package/dist/index.js +32 -31
- package/dist/index.js.map +1 -1
- package/dist/{init-3ZJ7Z5NG.js → init-FLGLJCMJ.js} +13 -13
- package/dist/{launch-FRCDOB56.js → launch-AVKH2JVF.js} +10 -10
- package/dist/{lighthouse-airtable-I46QCEIS.js → lighthouse-airtable-CHANWVO6.js} +3 -3
- package/dist/{onboard-45NPDMMW.js → onboard-HF4FTJDE.js} +5 -5
- package/dist/{orchestrate-34JYNIXT.js → orchestrate-5FMNYJEA.js} +3 -3
- package/dist/{preflight-WVD32COX.js → preflight-HTWIX3MN.js} +4 -4
- package/dist/prismic-ci-BH225Q4L.js +363 -0
- package/dist/prismic-ci-BH225Q4L.js.map +1 -0
- package/dist/prismic-models-HXCAM4ZL.js +1020 -0
- package/dist/prismic-models-HXCAM4ZL.js.map +1 -0
- package/dist/{protection-audit-2UR45SOC.js → protection-audit-64PLLBLI.js} +3 -3
- package/dist/recipes/sync-configs.d.ts +1 -1
- package/dist/{renovate-dispatch-LOOCNZTW.js → renovate-dispatch-HBZG2UEU.js} +4 -4
- package/dist/{report-CLF2753O.js → report-LV7NY5W3.js} +20 -13
- package/dist/report-LV7NY5W3.js.map +1 -0
- package/dist/{self-updating-5TJ2NB26.js → self-updating-4Q65OVXY.js} +6 -6
- package/dist/{selftest-BTCZLCTL.js → selftest-K5XZKVTE.js} +7 -7
- package/dist/{smoke-suite-GHKXMKIR.js → smoke-suite-J3LL3JZJ.js} +6 -6
- package/dist/{submissions-KUXLKCSQ.js → submissions-GMBUP4LV.js} +2 -2
- package/dist/{svelte-codemods-YTCTLK7G.js → svelte-codemods-JTMX4BSX.js} +4 -4
- package/dist/{sync-configs-KSSQZU5L.js → sync-configs-5DXSL64Q.js} +4 -4
- package/dist/{types-DnEL3NRn.d.ts → types-OIQZ5hWN.d.ts} +1 -1
- package/dist/{upgrade-UDAPY3SN.js → upgrade-HCR25P25.js} +5 -5
- package/dist/{websites-JXG3WWAU.js → websites-VITQ3WXN.js} +6 -2
- package/dist/{write-audits-to-airtable-7D3LV4KP.js → write-audits-to-airtable-3SOXLKJK.js} +4 -4
- package/package.json +1 -1
- package/dist/airtable-ZDHLKK5K.js +0 -9
- package/dist/chunk-2BHSGLWR.js.map +0 -1
- package/dist/chunk-KRDLHAKK.js +0 -17
- package/dist/chunk-KRDLHAKK.js.map +0 -1
- package/dist/chunk-UH5YPYS4.js.map +0 -1
- package/dist/chunk-VXXF4Y25.js.map +0 -1
- package/dist/chunk-YRM6EED7.js.map +0 -1
- package/dist/digest-CLFUWW62.js.map +0 -1
- package/dist/report-CLF2753O.js.map +0 -1
- /package/dist/{airtable-ZDHLKK5K.js.map → airtable-5ZPU3MT4.js.map} +0 -0
- /package/dist/{announce-7QHQGJPV.js.map → announce-4H64L2Z6.js.map} +0 -0
- /package/dist/{blux-EATHOHKK.js.map → blux-ZGDSHI3U.js.map} +0 -0
- /package/dist/{bump-deps-ZREAMK7M.js.map → bump-deps-2OL6PGTM.js.map} +0 -0
- /package/dist/{chunk-OIDDJLS7.js.map → chunk-2WN4SNBQ.js.map} +0 -0
- /package/dist/{chunk-HVOOCK6L.js.map → chunk-4DHQCN5H.js.map} +0 -0
- /package/dist/{chunk-5E4L7WKE.js.map → chunk-5P2HVNLZ.js.map} +0 -0
- /package/dist/{chunk-ZXRRKKMR.js.map → chunk-6V7565O2.js.map} +0 -0
- /package/dist/{chunk-B5ZUQZFL.js.map → chunk-ICXV25N7.js.map} +0 -0
- /package/dist/{chunk-4PW6ISX7.js.map → chunk-KV7DIRRO.js.map} +0 -0
- /package/dist/{chunk-ODC4PXZL.js.map → chunk-MQUOUVQP.js.map} +0 -0
- /package/dist/{chunk-LMCO7CYQ.js.map → chunk-OQFOMKBP.js.map} +0 -0
- /package/dist/{chunk-FFI7P74K.js.map → chunk-PBCGXXG6.js.map} +0 -0
- /package/dist/{chunk-T4FQ6TZT.js.map → chunk-QRDB4NLB.js.map} +0 -0
- /package/dist/{chunk-K5JZ5KLO.js.map → chunk-R4AB6GCT.js.map} +0 -0
- /package/dist/{chunk-FX7RAMGC.js.map → chunk-R6FPFMOS.js.map} +0 -0
- /package/dist/{chunk-MGJ72BA4.js.map → chunk-RJB3QUSP.js.map} +0 -0
- /package/dist/{chunk-ATJ6ZA2V.js.map → chunk-SDUJIANJ.js.map} +0 -0
- /package/dist/{chunk-7ADQL5Y3.js.map → chunk-THEFTZ7F.js.map} +0 -0
- /package/dist/{chunk-NT4WIP7I.js.map → chunk-UYCAF77E.js.map} +0 -0
- /package/dist/{chunk-YAMRVXOD.js.map → chunk-VRWUL4TR.js.map} +0 -0
- /package/dist/{chunk-4NAOR44V.js.map → chunk-ZMIPG6TR.js.map} +0 -0
- /package/dist/{convert-to-pnpm-UT2R6EOW.js.map → convert-to-pnpm-KCXOYOHW.js.map} +0 -0
- /package/dist/{ensure-site-LLPOXLWP.js.map → ensure-site-HYAIVZY2.js.map} +0 -0
- /package/dist/{forms-notify-target-PN6P3WPW.js.map → forms-notify-target-HSL4ZMT5.js.map} +0 -0
- /package/dist/{github-signals-HTH7474O.js.map → github-signals-I7XACMCE.js.map} +0 -0
- /package/dist/{header-image-WMHZFU7B.js.map → header-image-ADYPTGVY.js.map} +0 -0
- /package/dist/{health-endpoint-4E53ZMLE.js.map → health-endpoint-JNCUILHO.js.map} +0 -0
- /package/dist/{init-3ZJ7Z5NG.js.map → init-FLGLJCMJ.js.map} +0 -0
- /package/dist/{launch-FRCDOB56.js.map → launch-AVKH2JVF.js.map} +0 -0
- /package/dist/{lighthouse-airtable-I46QCEIS.js.map → lighthouse-airtable-CHANWVO6.js.map} +0 -0
- /package/dist/{onboard-45NPDMMW.js.map → onboard-HF4FTJDE.js.map} +0 -0
- /package/dist/{orchestrate-34JYNIXT.js.map → orchestrate-5FMNYJEA.js.map} +0 -0
- /package/dist/{preflight-WVD32COX.js.map → preflight-HTWIX3MN.js.map} +0 -0
- /package/dist/{protection-audit-2UR45SOC.js.map → protection-audit-64PLLBLI.js.map} +0 -0
- /package/dist/{renovate-dispatch-LOOCNZTW.js.map → renovate-dispatch-HBZG2UEU.js.map} +0 -0
- /package/dist/{self-updating-5TJ2NB26.js.map → self-updating-4Q65OVXY.js.map} +0 -0
- /package/dist/{selftest-BTCZLCTL.js.map → selftest-K5XZKVTE.js.map} +0 -0
- /package/dist/{smoke-suite-GHKXMKIR.js.map → smoke-suite-J3LL3JZJ.js.map} +0 -0
- /package/dist/{submissions-KUXLKCSQ.js.map → submissions-GMBUP4LV.js.map} +0 -0
- /package/dist/{svelte-codemods-YTCTLK7G.js.map → svelte-codemods-JTMX4BSX.js.map} +0 -0
- /package/dist/{sync-configs-KSSQZU5L.js.map → sync-configs-5DXSL64Q.js.map} +0 -0
- /package/dist/{upgrade-UDAPY3SN.js.map → upgrade-HCR25P25.js.map} +0 -0
- /package/dist/{websites-JXG3WWAU.js.map → websites-VITQ3WXN.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-7D3LV4KP.js.map → write-audits-to-airtable-3SOXLKJK.js.map} +0 -0
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/digest-collectors.ts","../src/alerts/digest-state.ts"],"sourcesContent":["// src/alerts/digest-collectors.ts\nimport type { AttentionItem } from \"./attention.js\";\nimport {\n allActionableVulnsTransitive,\n siteSlug,\n type WebsiteRow,\n} from \"../reports/airtable/websites.js\";\nimport type { ReportRow } from \"../reports/airtable/reports.js\";\nimport { approveBlockers } from \"../reports/preflight.js\";\n\n/** Build the same `/s/<slug>` dashboard link the M3 ready-section uses, trailing-slash-safe.\n * An empty Name slugs to \"\" and `/s/` is a dead link — fall back to the fleet homepage,\n * exactly like the ready-section does. */\nfunction dashboardUrl(baseUrl: string, siteName: string): string {\n const root = baseUrl.replace(/\\/$/, \"\");\n const slug = siteSlug(siteName);\n return slug ? `${root}/s/${slug}` : root;\n}\n\n/**\n * A GitHub-signals sweep older than this (or never run) is no longer trustworthy:\n * a repo whose nightly probe THREW stops being re-swept, so its persisted\n * `Default Branch CI` / `Renovate Failing CIs` freeze at their last value forever\n * — a phantom 🔴 that can never clear. 3 days ≈ 3× the daily sweep interval, so a\n * single missed/flaky run doesn't drop a real signal. The CI/Renovate collectors\n * (only) skip a site whose `githubSignalsAt` is staler than this. Vuln/Lighthouse/\n * delivery signals come from other sweeps and are unaffected.\n */\nconst GITHUB_SIGNALS_STALE_DAYS = 3;\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/** True when a site's GitHub-signals sweep is too old (or never ran) to trust the\n * persisted CI/Renovate fields. A null `githubSignalsAt` (never swept) is stale —\n * there's no sweep to vouch for the values. A future timestamp (now - swept < 0)\n * is fresh. `now` is injected so the gate is testable. */\nfunction gitHubSignalsStale(swept: string | null, now: Date): boolean {\n if (swept === null) return true;\n const ageMs = now.getTime() - Date.parse(swept);\n if (!Number.isFinite(ageMs)) return true; // unparseable timestamp → don't trust it\n return ageMs > GITHUB_SIGNALS_STALE_DAYS * MS_PER_DAY;\n}\n\n/** Renovate auto-fix dispatches for one vuln episode before it's \"exhausted\" (manual fix needed). */\nconst AUTO_FIX_EXHAUSTED_CYCLES = 3;\n\n/**\n * One attention item per site carrying current critical+high vulns (medium/low omitted\n * per the locked threshold). PURE: takes already-fetched Websites rows. `metric` is the\n * critical+high count (so a rising count diffs as WORSE); `severity` is `critical` when\n * any critical exists, else `warning`. Null counts (never audited) read as 0 → skipped.\n * Once `securityAutoFixAttempts` reaches AUTO_FIX_EXHAUSTED_CYCLES the item is flagged\n * `autoFixExhausted` (forced-critical, escalated title) — Renovate tried and couldn't fix it.\n * EXCEPT when the persisted advisories prove every critical/high vuln is TRANSITIVE: then\n * nightly dispatches were green no-ops (no direct dep to bump — the fix rides the weekly\n * lockfile window), \"auto-fix failed\" would be a lie, and a stale pre-fix counter must not\n * escalate (Sonder said \"auto-fix failed (5×)\" on exactly this, 2026-08-10). Those sites get\n * an honest transitive-only title, keep count-based severity, and stay out of the digest\n * email (amber cockpit Watch still shows them). Unknown relationship data never mutes.\n *\n * Emission is UNCONDITIONAL — every surface applies its own policy on top:\n * - digest email: includes a vuln only when `autoFixExhausted` (the operator hears\n * about a vuln only after Renovate tried and failed; before that the fleet is\n * still self-patching) — runDigest filters\n * - cockpit: non-exhausted vuln → amber Watch; exhausted → hard break (assignTier),\n * and only an exhausted vuln pierces the pre-launch mute (piercesPreLaunchMute)\n */\nexport function collectVulnAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const critical = s.securityVulnsCritical ?? 0;\n const high = s.securityVulnsHigh ?? 0;\n const metric = critical + high;\n if (metric <= 0) continue;\n const attempts = s.securityAutoFixAttempts ?? 0;\n const transitiveOnly = allActionableVulnsTransitive(s.securityAdvisories);\n const exhausted = !transitiveOnly && attempts >= AUTO_FIX_EXHAUSTED_CYCLES;\n const noun = metric === 1 ? \"vuln\" : \"vulns\";\n items.push({\n key: `vuln:${s.id}`,\n kind: \"vuln\",\n siteName: s.name,\n title: exhausted\n ? `${metric} critical/high ${noun} — auto-fix failed (${attempts}×)`\n : transitiveOnly\n ? `${metric} critical/high ${noun} — transitive-only, fix rides the weekly lockfile window`\n : `${metric} critical/high ${noun}`,\n url: dashboardUrl(baseUrl, s.name),\n severity: exhausted || critical > 0 ? \"critical\" : \"warning\",\n metric,\n ...(exhausted ? { autoFixExhausted: true } : {}),\n });\n }\n return items;\n}\n\n/** Absolute floor below which a Lighthouse category is \"Needs attention\" (Tucker's call). */\nconst LIGHTHOUSE_FLOOR = 75;\n\n/** The four Lighthouse categories, each mapped to its WebsiteRow score field, URL slug,\n * and the human label rendered in the digest title. Order is the operator's reading order. */\nconst LIGHTHOUSE_CATEGORIES: ReadonlyArray<{\n field: \"pScore\" | \"rScore\" | \"bpScore\" | \"seoScore\";\n slug: string;\n label: string;\n}> = [\n { field: \"pScore\", slug: \"performance\", label: \"Performance\" },\n { field: \"rScore\", slug: \"accessibility\", label: \"Accessibility\" },\n { field: \"bpScore\", slug: \"best-practices\", label: \"Best Practices\" },\n { field: \"seoScore\", slug: \"seo\", label: \"SEO\" },\n];\n\n/**\n * One attention item per Lighthouse category below the absolute floor (75) for each site.\n * PURE: takes already-fetched Websites rows. Categories are Performance/Accessibility/\n * Best-Practices/SEO. A null score (never audited) or a score >= 75 is skipped. The\n * `metric` is the DEFICIT (`100 - score`): a lower score → higher metric, so a category\n * that drops further diffs as WORSE and one that first crosses below 75 diffs as NEW —\n * which is how `diffAttention`'s \"WORSE on increase\" rule reads an inverted score. `key`\n * is `lighthouse:<siteId>:<categorySlug>`, so the four categories stay distinct per site.\n */\nexport function collectLighthouseAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n for (const cat of LIGHTHOUSE_CATEGORIES) {\n const score = s[cat.field];\n if (score === null || score >= LIGHTHOUSE_FLOOR) continue;\n items.push({\n key: `lighthouse:${s.id}:${cat.slug}`,\n kind: \"lighthouse\",\n siteName: s.name,\n title: `Lighthouse ${cat.label} ${score} (below ${LIGHTHOUSE_FLOOR})`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 100 - score,\n });\n }\n }\n return items;\n}\n\n/**\n * One attention item per report whose `deliveryStatus` is a failure (`bounced` or\n * `complained` — `delivered`/`pending` are ignored). PURE: takes already-fetched\n * Reports rows + a record-id→site map. A complaint ranks above a bounce (locked\n * threshold), so `severity` is `critical` for complained / `warning` for bounced.\n * `metric` is 1 (a binary event). Orphans (siteId not in the map) are skipped, as\n * the M3 ready-section does, so the digest never renders a broken link. The diff\n * key is the report RECORD id, so two failures on one site stay distinct.\n */\n/**\n * One attention item per unsent draft whose send is ALREADY known to fail\n * (approveBlockers: recipients / header image / report scores). An APPROVED one\n * is critical — the next 09:23 UTC run will go red on it; a pending one is a\n * warning — approving it just schedules that failure. Keyed `preflight:<reportId>`.\n * PURE; same predicate the approve gate and the dashboard chip use.\n */\nexport function collectPreflightBlocked(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (!r.draftReady || r.sentAt !== null) continue;\n // The approved state rides the KEY so a pending→approved escalation with\n // unchanged blockers re-news as a fresh critical instead of diffing\n // \"standing\" against its old warning self.\n const state = r.approvedToSend ? \"approved\" : \"pending\";\n const site = sitesById.get(r.siteId);\n if (!site) {\n // A dangling/empty Site link is itself a send blocker (sendApprovedReports\n // fails with \"Site row not found\") — surface it; the empty-slug\n // dashboardUrl fallback yields the fleet root, not a broken /s/ link.\n items.push({\n key: `preflight:${r.id}:${state}`,\n kind: \"preflight\",\n siteName: \"(unlinked site)\",\n title: r.approvedToSend\n ? `Approved ${r.reportType} will fail at send — site-not-found`\n : `${r.reportType} draft can't be approved — site-not-found`,\n url: dashboardUrl(baseUrl, \"\"),\n severity: r.approvedToSend ? \"critical\" : \"warning\",\n metric: 1,\n });\n continue;\n }\n const fails = approveBlockers(site, r).filter((f) => f.level === \"fail\");\n if (fails.length === 0) continue;\n const first = fails[0]!;\n const more = fails.length > 1 ? ` (+${fails.length - 1} more)` : \"\";\n items.push({\n key: `preflight:${r.id}:${state}`,\n kind: \"preflight\",\n siteName: site.name,\n title: r.approvedToSend\n ? `Approved ${r.reportType} will fail at send — ${first.check}${more}`\n : `${r.reportType} draft can't be approved — ${first.check}${more}`,\n url: dashboardUrl(baseUrl, site.name),\n severity: r.approvedToSend ? \"critical\" : \"warning\",\n metric: fails.length,\n });\n }\n return items;\n}\n\nexport function collectDeliveryFailures(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (r.deliveryStatus !== \"bounced\" && r.deliveryStatus !== \"complained\") continue;\n const site = sitesById.get(r.siteId);\n if (!site) continue; // orphan → skip rather than render a broken link\n const complained = r.deliveryStatus === \"complained\";\n items.push({\n key: `delivery:${r.id}`,\n kind: \"delivery\",\n siteName: site.name,\n title: complained ? \"Spam complaint on a sent report\" : \"A sent report bounced\",\n url: dashboardUrl(baseUrl, site.name),\n severity: complained ? \"critical\" : \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site carrying failing Renovate PRs, read from the\n * slice-2a-persisted `renovateFailingCis` field (the nightly github-signals sweep\n * populates it). PURE. Keyed `renovate:<siteId>` so the digest and the cockpit\n * share one diff key. `metric` is the count (a rising count diffs WORSE); severity\n * `warning`. Null/0 → skipped. A site whose `githubSignalsAt` is >3 days stale (or\n * null) is ALSO skipped — a repo that stopped being swept must not show a phantom\n * count forever (`now` injected, defaults to wall-clock).\n */\nexport function collectRenovateAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n const n = s.renovateFailingCis ?? 0;\n if (n <= 0) continue;\n items.push({\n key: `renovate:${s.id}`,\n kind: \"renovate\",\n siteName: s.name,\n title: `${n} Renovate ${n === 1 ? \"PR\" : \"PRs\"} failing CI`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: n,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site whose persisted default-branch CI rollup is\n * `failing` (slice 2a). PURE. `metric` 1 (binary); severity `warning`. Any other\n * state (passing/pending/none) or null is skipped. A site whose `githubSignalsAt`\n * is >3 days stale (or null) is ALSO skipped — a repo that stopped being swept must\n * not show a phantom 🔴 forever (`now` injected, defaults to wall-clock).\n */\nexport function collectCiAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n if (s.defaultBranchCi !== \"failing\") continue;\n items.push({\n key: `ci:${s.id}`,\n kind: \"ci\",\n siteName: s.name,\n title: \"Default-branch CI failing\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * A soft-fail older than this is no longer a trustworthy CURRENT signal. Drafting\n * self-clears `analyticsSoftFailAt` on the next clean enrichment, so a non-null\n * value normally means \"errored on the most recent draft and hasn't recovered\" —\n * but a site that stopped being drafted (freq→None, deprecated) would otherwise\n * show a phantom forever. 45 days covers a monthly cadence + margin; older drops.\n */\nconst ANALYTICS_SOFT_FAIL_STALE_DAYS = 45;\n\n/**\n * One attention item per site whose last draft's GA/Search enrichment ERRORED, read\n * from the `analyticsSoftFailAt` timestamp (drafting sets it on a soft-fail, clears\n * it on a clean enrichment). PURE. Keyed `analytics:<siteId>` so the digest and the\n * cockpit share one diff key; `metric` 1 (binary), severity `warning`. A null\n * timestamp (clean, or the operator-added `Analytics soft-fail at` column absent) is\n * skipped, as is one staler than {@link ANALYTICS_SOFT_FAIL_STALE_DAYS}. On a\n * FLEET-WIDE subject outage many sites surface this at once — that breadth IS the\n * signal here; the report cron additionally emails a single concise fleet-wide alert\n * (see `assessAnalyticsAlert`). `now` injected, defaults to wall-clock.\n */\n/** A function-health sweep older than this can't confirm the CURRENT widget state.\n * The sweep is nightly, so 3 days (mirrors GITHUB_SIGNALS_STALE_DAYS) tolerates a\n * weekend of runner flakes without letting a months-old verdict drive an alarm. */\nconst TURNSTILE_WIDGET_STALE_DAYS = 3;\n\n/**\n * One CRITICAL attention item per site with `Require Turnstile` ON whose deployed\n * `/health` reports the Turnstile widget NOT configured (`turnstileWidget === \"fail\"`,\n * fresh per {@link TURNSTILE_WIDGET_STALE_DAYS}). That combination silently buckets\n * 100% of the site's real leads (token-less submissions escalate to spam_auto with\n * notify skipped) and the form-e2e probe cannot see it — testMode bypasses the gate —\n * so the operator's inbox just goes quiet. PURE. Keyed `turnstile:<siteId>`. Because\n * this is an AttentionItem it rides assignTier's items short-circuit, which sits ABOVE\n * the accepted-watch mute loop — an accept key can never silence it. A null verdict or\n * a stale sweep is NOT alarmed here; assignTier raises those as an acceptable\n * \"can't verify\" watch instead. `now` injected, defaults to wall-clock.\n */\nexport function collectTurnstileGuardrailAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (!s.requireTurnstile || s.turnstileWidget !== \"fail\") continue;\n const at = s.functionHealthCheckedAt;\n if (at !== null) {\n const ageMs = now.getTime() - Date.parse(at);\n // Parseable and beyond the window → stale, downgraded to assignTier's watch.\n // Unparseable (NaN) keeps the item — never silently drop a real failure.\n if (Number.isFinite(ageMs) && ageMs > TURNSTILE_WIDGET_STALE_DAYS * MS_PER_DAY) continue;\n }\n items.push({\n key: `turnstile:${s.id}`,\n kind: \"turnstile\",\n siteName: s.name,\n title:\n \"Require Turnstile is ON but /health reports no widget — real leads are being auto-bucketed\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"critical\",\n metric: 1,\n });\n }\n return items;\n}\n\n/** Bounced lead notifications inside the window before a site alarms. One bounce can\n * be a transient greylist/full-mailbox blip; a repeat says the point-of-contact\n * address itself is bad (the Espada mode: apm@ bounced 4 of 8 with nothing alarming). */\nexport const NOTIFY_BOUNCE_THRESHOLD = 2;\n\n/** Window (days) the bounce count covers. Two weeks: long enough that a low-volume\n * site's second bounce still lands in the same window as its first, short enough\n * that a long-fixed address doesn't alarm forever (bounced rows never self-clear —\n * the alarm ages out as bounced submissions leave the window). */\nexport const NOTIFY_BOUNCE_WINDOW_DAYS = 14;\n\n/**\n * One CRITICAL attention item per site with >= {@link NOTIFY_BOUNCE_THRESHOLD} bounced\n * lead notifications in the last {@link NOTIFY_BOUNCE_WINDOW_DAYS} days — leads are\n * being captured but silently never reach the client, which is worse than no form at\n * all (notifyStatus \"sent\" only means Resend accepted the email; the resend-webhook\n * flips it to \"bounced\" when Resend later reports the failure, 2026-07-16). PURE:\n * takes the pre-fetched per-site counts (`countNotifyBouncedBySite`, keyed by the\n * Websites record id). Keyed `notify-bounce:<siteId>` so the digest and the cockpit\n * share one diff key; `metric` is the bounce count (a rising count diffs WORSE).\n * A site absent from the map (or below threshold) is skipped, as is a count for a\n * site id not in `sites` (orphan → never render a broken link).\n */\nexport function collectNotifyBounceAlerts(\n sites: WebsiteRow[],\n bouncedBySite: ReadonlyMap<string, number>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const n = bouncedBySite.get(s.id) ?? 0;\n if (n < NOTIFY_BOUNCE_THRESHOLD) continue;\n items.push({\n key: `notify-bounce:${s.id}`,\n kind: \"notify-bounce\",\n siteName: s.name,\n title: `${n} lead notifications bounced (${NOTIFY_BOUNCE_WINDOW_DAYS}d) — check the point-of-contact address`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"critical\",\n metric: n,\n });\n }\n return items;\n}\n\nexport function collectAnalyticsFailures(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const at = s.analyticsSoftFailAt;\n if (at === null) continue;\n const ageMs = now.getTime() - Date.parse(at);\n // Unparseable timestamp (NaN) → keep the item (don't silently drop a real failure\n // on a parse glitch); only a parseable, beyond-window age is skipped as stale.\n if (Number.isFinite(ageMs) && ageMs > ANALYTICS_SOFT_FAIL_STALE_DAYS * MS_PER_DAY) continue;\n items.push({\n key: `analytics:${s.id}`,\n kind: \"analytics\",\n siteName: s.name,\n title: \"GA/Search enrichment failing (analytics blank)\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n","// src/alerts/digest-state.ts\nimport type { FieldSet } from \"airtable\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport type { AttentionItem } from \"./attention.js\";\n\n/**\n * The persisted prior-run snapshot: stable item `key` → its last metric + the\n * date it was FIRST flagged. Lives as JSON in the single \"Digest State\" Airtable\n * row (the IO that loads/stores it — readDigestState/writeDigestState — is added\n * in component 2). `next` from diffAttention is what gets written back.\n * `exhausted` is carried only for vuln keys whose Renovate auto-fix has been\n * exhausted (absent = not exhausted; older snapshots without the field read the\n * same way).\n */\nexport type DigestSnapshot = Record<\n string,\n { metric: number; firstFlaggedAt: string; exhausted?: boolean }\n>;\n\n/**\n * PURE diff — the testable core of the hybrid \"snapshot now, mark what's new\".\n * For each current item vs the prior snapshot:\n * - key absent from prior → NEW (firstFlaggedAt = today)\n * - present and metric > prior.metric → WORSE (keep the original firstFlaggedAt)\n * - present and autoFixExhausted flipped on → WORSE (the count didn't move, but\n * \"Renovate gave up\" is an escalation — without this the digest's first mention\n * of a vuln, which is gated on exhaustion, would arrive unbadged)\n * - otherwise (equal or dropped) → STANDING (keep the original firstFlaggedAt)\n * `next` contains EXACTLY the current items' keys: resolved keys drop out, so a\n * fixed-then-recurring problem re-news correctly. Neither input is mutated.\n */\nexport function diffAttention(\n items: AttentionItem[],\n prior: DigestSnapshot,\n today: string,\n): { tagged: AttentionItem[]; next: DigestSnapshot } {\n const tagged: AttentionItem[] = [];\n const next: DigestSnapshot = {};\n for (const it of items) {\n const was = prior[it.key];\n const exhaustedFlip = it.autoFixExhausted === true && was?.exhausted !== true;\n let status: AttentionItem[\"status\"];\n let firstFlaggedAt: string;\n if (!was) {\n status = \"new\";\n firstFlaggedAt = today;\n } else if (it.metric > was.metric || exhaustedFlip) {\n status = \"worse\";\n firstFlaggedAt = was.firstFlaggedAt;\n } else {\n status = \"standing\";\n firstFlaggedAt = was.firstFlaggedAt;\n }\n tagged.push({ ...it, status });\n next[it.key] = {\n metric: it.metric,\n firstFlaggedAt,\n ...(it.autoFixExhausted === true ? { exhausted: true } : {}),\n };\n }\n return { tagged, next };\n}\n\n/** The single-row Airtable table that persists the prior digest snapshot. */\nexport const DIGEST_STATE_TABLE = \"Digest State\";\n\n/**\n * Read the persisted prior snapshot from the \"Digest State\" singleton.\n *\n * Reads the FIRST row of an unfiltered select (the table holds exactly one row;\n * the test fake does not evaluate filterByFormula, so we never rely on one). A\n * read miss (no row) OR a parse error (malformed Snapshot JSON) collapses to `{}`\n * — every key then reads as NEW once, which is safe degradation (never crashes\n * the digest).\n */\nexport async function readDigestState(base: AirtableBase): Promise<DigestSnapshot> {\n const rows: { id: string; fields: Record<string, unknown> }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id, fields: rec.fields });\n fetchNextPage();\n });\n const first = rows[0];\n if (!first) return {};\n const raw = first.fields[\"Snapshot\"];\n if (typeof raw !== \"string\") return {};\n try {\n return JSON.parse(raw) as DigestSnapshot;\n } catch {\n return {};\n }\n}\n\n/**\n * Persist the next snapshot to the \"Digest State\" singleton: get-or-create the\n * one row. If a row exists, UPDATE it (keyed by its record id); otherwise CREATE\n * one. `Snapshot` = JSON.stringify(snap); `Updated At` = the injected ISO\n * timestamp (or now). A caller that catches+logs a write failure keeps the\n * already-sent digest unaffected (next run re-news at worst).\n */\nexport async function writeDigestState(\n base: AirtableBase,\n snap: DigestSnapshot,\n updatedAt: string = new Date().toISOString(),\n): Promise<void> {\n const rows: { id: string }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id });\n fetchNextPage();\n });\n const fields: FieldSet = {\n Snapshot: JSON.stringify(snap),\n \"Updated At\": updatedAt,\n };\n const existing = rows[0];\n if (existing) {\n await base(DIGEST_STATE_TABLE).update([{ id: existing.id, fields }]);\n } else {\n await base(DIGEST_STATE_TABLE).create([{ fields }]);\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/reports/airtable/websites.ts"],"sourcesContent":["import type { FieldSet } from \"airtable\";\nimport type { AirtableBase } from \"./client.js\";\nimport type { LighthouseScores, LighthouseScoreWriteback } from \"../types.js\";\n\nexport const WEBSITES_TABLE = \"Websites\";\n\nexport type Frequency = \"None\" | \"Monthly\" | \"Quarterly\" | \"Yearly\";\n\nexport type Status =\n | \"in development\"\n | \"launch period\"\n | \"maintenance\"\n | \"hosting\"\n | \"probably not our problem\"\n | \"deprecated\"\n | \"legacy\";\n\n/**\n * Per-site notification routing. When present on a `maintenance` site, the form\n * notification is addressed by the value of a submission field (`field`, read from\n * `extraFields`) — e.g. route a contact form's `interest` to a different recipient\n * per option, always CC-ing a shared address. Absent (`null`) → the site keeps the\n * default single-POC behavior. Recipients live HERE (server-side Airtable config),\n * never supplied by the submitting site, so the ingest can't be turned into an open\n * relay.\n */\nexport type NotifyRouting = {\n /** The `extraFields` key whose value selects a route, e.g. \"interest\". */\n field: string;\n /** Field-value → recipient address(es). */\n routes: Record<string, string | string[]>;\n /** Recipient(s) when the value matches no route. */\n default?: string | string[];\n /** Address(es) CC'd on every routed (maintenance) send. */\n cc?: string[];\n};\n\nexport type WebsiteRow = {\n id: string;\n name: string;\n url: string;\n status: Status | null;\n pointOfContact: string | null;\n maintenanceFreq: Frequency;\n testingFreq: Frequency;\n /** The literal Airtable cell values behind the coerced frequencies. `toFrequency`\n * trims whitespace (\"Monthly \" reads as Monthly), then maps any still-unrecognized\n * value (\"Quaterly\") to \"None\" with a LOUD console.warn so nothing bogus reaches a\n * client email and the drop is never invisible. Preflight validates THESE to surface\n * the same failure as a structured finding. Null = blank cell. */\n maintenanceFreqRaw: string | null;\n testingFreqRaw: string | null;\n /** Last manually-recorded maintenance day (used as fallback when no Reports row exists). */\n maintenanceDay: string | null;\n testingDay: string | null;\n ga4PropertyId: string | null;\n /** Operator-supplied query for the Google search-presence check (e.g. the business name).\n * Null = no query set → the check is skipped for this site. */\n searchQuery: string | null;\n /** Explicit Search Console property for this site (`sc-domain:...` or `https://.../`).\n * Null = auto-resolve from the SA's visible properties by host. */\n searchConsoleProperty: string | null;\n /** ISO timestamp of the last draft run where THIS site's GA/Search enrichment\n * ERRORED (vs a legitimate \"not configured\" skip). Set by drafting on a soft-fail,\n * cleared (null) on a clean enrichment, so the per-site analytics-failure signal\n * self-heals. Null when the operator-added `Analytics soft-fail at` column is absent. */\n analyticsSoftFailAt: string | null;\n /** GitHub repo identity as `owner/repo`. Null = no git wiring → self-update ops skip\n * (or, for local runs, fall back to the checkout's origin remote). */\n gitRepo: string | null;\n reportRecipientsTo: string | null;\n reportRecipientsCc: string | null;\n acceptedWatchConditions: string[];\n /** First attachment in the Header image field (Airtable's signed URL — fetch before expiry). */\n headerImage: { url: string; filename: string; type: string } | null;\n /** Lighthouse \"current state\" snapshot, kept fresh by `audit lighthouse --write-airtable`. */\n pScore: number | null;\n rScore: number | null;\n bpScore: number | null;\n seoScore: number | null;\n /** ISO timestamp set by `audit lighthouse --write-airtable` when scores were last refreshed. */\n lastLighthouseAuditAt: string | null;\n /** Last-known counts from non-lighthouse audits, written by\n * `audit --write-airtable`. `null` = never audited (or this audit\n * type was skipped on the last run). 0 = audited, clean. */\n a11yViolations: number | null;\n /** Declared-range drift vs the Reddoor baseline (what package.json asks for). */\n depsDrifted: number | null;\n depsMajorBehind: number | null;\n /** Real installed-version drift: deps behind the registry's latest, from the\n * committed lockfile (`pnpm outdated`). Null = not determined this run. */\n depsOutdated: number | null;\n /** Of {@link depsOutdated}, how many are a *major* version behind the\n * registry's latest — the \"majors available on npm\" signal, distinct from the\n * baseline-drift {@link depsMajorBehind}. Null = not determined this run. */\n depsMajorOutdated: number | null;\n securityVulnsCritical: number | null;\n securityVulnsHigh: number | null;\n securityVulnsModerate: number | null;\n securityVulnsLow: number | null;\n /** Count of consecutive nightly Renovate auto-fix dispatches for the CURRENT\n * critical/high vuln episode that have NOT yet cleared it. Owned by\n * `renovate-dispatch`: +1 per real dispatch, reset to 0 when vulns clear.\n * Null = field absent / never dispatched → reads as 0. At/above\n * AUTO_FIX_EXHAUSTED_CYCLES the vuln renders as \"auto-fix failed\". */\n securityAutoFixAttempts: number | null;\n /** ISO timestamp the security audit last ran — gates freshness of the Security Updates auto-tick\n * (clean counts only auto-tick when recent). */\n lastSecurityAuditAt: string | null;\n /** The known advisories behind the counts (severity-sorted, capped), so the dashboard can show\n * WHICH packages are vulnerable, not just the totals. null = never audited / unparseable;\n * empty array = audited clean. Written alongside the counts by the security audit. */\n securityAdvisories: SecurityAdvisory[] | null;\n /** Domain/DNS/SSL probe (the `domain` audit). `certDaysRemaining` is days until the TLS cert\n * expires (null = unresolved or no usable cert); `domainCheckedAt` is when it last ran. */\n certDaysRemaining: number | null;\n domainCheckedAt: string | null;\n /** Netlify site id — the IDENTITY the `netlify-deploy` audit needs to query the\n * Netlify API. Read-only input (operator-set in Airtable); null = not on Netlify\n * (or not wired) → that audit skips. Never derived from the URL. */\n netlifyId: string | null;\n /** Latest PRODUCTION deploy health (the `netlify-deploy` audit). `deployStatus` is\n * Netlify's deploy state lower-cased (`ready`/`error`/`building`/…), null = none / not\n * checked; `lastDeployAt` is when it deployed; `deployLogUrl` links to the deploy/log. */\n deployStatus: string | null;\n lastDeployAt: string | null;\n deployLogUrl: string | null;\n /** When the `netlify-deploy` audit last RAN (freshness stamp for `deployStatus`). The audit\n * already writes \"Deploy checked at\"; this read-back is the Plan-2 fix so `deployEvidence`\n * (Plan 4) can gate on check time — NOT on `lastDeployAt`, which is deploy time, not check time. */\n deployCheckedAt: string | null;\n /** Function-health verdict (the `function-health` audit): the deployed `/health` function\n * answered `ok:true` (pass) or `ok:false` (fail). Single-select `pass`/`fail`; null = never ran\n * / unreachable (→ Plan 4 maps to unknown/amber). Kept SEPARATE from `deployStatus` so\n * `isFailedDeployStatus` keeps meaning \"the build failed\". */\n functionHealth: \"pass\" | \"fail\" | null;\n /** CMS reachability (server-side), derived from the same `/health` body's `details.prismic ===\n * \"ok\"`. Single-select `pass`/`fail`; null = never ran. No per-site Prismic token or identity\n * column is ever built — this rides `/health`. */\n cmsReachable: \"pass\" | \"fail\" | null;\n /** From the same `/health` body's `forms.turnstile` boolean: does the deployed site report\n * the Turnstile widget configured (`PUBLIC_TURNSTILE_SITE_KEY` set)? Single-select\n * `pass`/`fail`; null = never ran / older site package whose `/health` has no `forms`\n * block. Freshness gated by `functionHealthCheckedAt`. Powers the cockpit Require-Turnstile\n * guardrail: a site with `requireTurnstile` ON but no widget silently buckets 100% of its\n * real leads (and form-e2e can't see it — testMode bypasses the gate). */\n turnstileWidget: \"pass\" | \"fail\" | null;\n /** When the `function-health` audit last ran — the freshness gate for `functionHealth`,\n * `cmsReachable`, and `turnstileWidget`. Null = never ran. */\n functionHealthCheckedAt: string | null;\n /** Deployed-URL browser probe (the `browser` audit): cross-engine render OK, mobile render OK,\n * internal-links OK + broken count, and when it last ran (one timestamp gates all three). */\n crossbrowserOk: boolean | null;\n mobileOk: boolean | null;\n linksOk: boolean | null;\n brokenLinks: number | null;\n browserCheckedAt: string | null;\n /** Uptime-reachable verdict (browser audit): every sampled route returned 2xx/3xx. Single-select\n * `pass`/`fail`; null = never ran. Point-in-time. Freshness-gated by `browserCheckedAt`. */\n reachableOk: \"pass\" | \"fail\" | null;\n /** Titles & meta verdict (browser audit, chromium): every sampled route has a non-empty `<title>`\n * ≤ 70 chars + a non-empty meta description, and no duplicate titles across the sample.\n * Single-select `pass`/`fail`; null = never ran. Freshness-gated by `browserCheckedAt`. */\n titleMetaOk: \"pass\" | \"fail\" | null;\n /** Per-site copy overrides (M6a). Blank → null → the DEFAULT_COPY value. */\n copyIntro: string | null;\n copyContact: string | null;\n copyFooter: string | null;\n /** Go-live timestamp, stamped when a Launch report sends (M6b). Null = not yet launched. */\n launchedAt: string | null;\n /** Optional per-site webhook (e.g. Zapier Catch Hook). When set, the ingest\n * POSTs newsletter-formType submissions here (best-effort). Blank → null. */\n newsletterWebhook: string | null;\n /** Per-site Mailchimp (newsletter). Both must be set for the direct add;\n * blank → skipped. The API key is `key-dc` format; dc is derived from it. */\n mailchimpApiKey: string | null;\n mailchimpAudienceId: string | null;\n /** Per-site Cloudflare Turnstile gate (Airtable checkbox). When true, a submission\n * whose Turnstile token verifies as \"fail\" (forged) OR \"absent\" (secret configured\n * centrally but NO token forwarded — the direct-POST-bot signature) is escalated to\n * auto-spam regardless of content score. A present-but-expired token stays\n * \"unverifiable\" and neutral (a real browser DID render the widget — fail-open).\n * ROLLOUT PRECONDITION: only enable on a site whose DEPLOYED package forwards\n * `_meta.turnstileToken` from EVERY form (widget rendered + `cf-turnstile-response`\n * posted; check the site's `/health` → forms.turnstile:true). A site that never\n * forwards tokens would silently bucket 100% of its real leads — and the form-e2e\n * probe cannot catch that state (testMode bypasses the gate). */\n requireTurnstile: boolean;\n /** GitHub-signals sweep (slice 2a), written nightly by `github-signals --fleet`. */\n renovateFailingCis: number | null;\n defaultBranchCi: string | null; // \"passing\" | \"failing\" | \"pending\" | \"none\"\n lastCommitAt: string | null;\n githubSignalsAt: string | null;\n /** Per-site smoke-suite verdict (the `smoke` audit runs `pnpm test:smoke`).\n * Single-select pass/fail; null = never ran. `lastSmokeAt` gates freshness. */\n smokeOk: \"pass\" | \"fail\" | null;\n lastSmokeAt: string | null;\n /** Synthetic form end-to-end verdict (the `form-e2e` audit submits the real prod\n * contact form in test-mode). Single-select pass/fail; null = never ran OR (with\n * a fresh `formE2eCheckedAt`) no contact form → n/a. `formE2eCheckedAt` gates\n * freshness AND encodes the n/a-vs-never-ran distinction. */\n formE2eOk: \"pass\" | \"fail\" | null;\n formE2eCheckedAt: string | null;\n notifyRouting: NotifyRouting | null;\n};\n\nexport function siteSlug(name: string): string {\n return name\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-|-$/g, \"\");\n}\n\n/** Blank-trim-to-null: a non-string or whitespace-only value becomes null,\n * otherwise the trimmed string. */\nfunction trimToNull(raw: unknown): string | null {\n if (typeof raw !== \"string\") return null;\n const trimmed = raw.trim();\n return trimmed.length > 0 ? trimmed : null;\n}\n\n/**\n * Parse the Websites `Notify Routing` JSON into a NotifyRouting, defensively: a\n * non-string, blank, malformed-JSON, or wrong-shape value yields null (the site\n * then keeps default single-POC routing) — never throws. Mirrors the pipeline's\n * \"a bad Airtable string degrades quietly\" rule.\n */\nexport function parseNotifyRouting(raw: unknown): NotifyRouting | null {\n if (typeof raw !== \"string\") return null;\n const trimmed = raw.trim();\n if (!trimmed) return null;\n let parsed: unknown;\n try {\n parsed = JSON.parse(trimmed);\n } catch {\n return null;\n }\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n const o = parsed as Record<string, unknown>;\n if (typeof o.field !== \"string\" || !o.field.trim()) return null;\n if (!o.routes || typeof o.routes !== \"object\" || Array.isArray(o.routes)) return null;\n const routing: NotifyRouting = {\n field: o.field,\n routes: o.routes as Record<string, string | string[]>,\n };\n if (o.default !== undefined) routing.default = o.default as string | string[];\n if (Array.isArray(o.cc)) routing.cc = o.cc.filter((x): x is string => typeof x === \"string\");\n return routing;\n}\n\n/**\n * Active sites: actively-maintained or pre-launch. Single source of truth for\n * \"is this a live site\" — the operator cockpit shows these, and the fleet\n * audit/report path runs against these. A `null` status (not-yet-active) is\n * deliberately excluded.\n */\nexport const ACTIVE_STATUSES: ReadonlySet<Status> = new Set<Status>([\n \"maintenance\",\n \"launch period\",\n]);\n\nexport function isDashboardVisible(site: WebsiteRow): boolean {\n return site.status !== null && ACTIVE_STATUSES.has(site.status);\n}\n\n/**\n * Pre-launch lifecycle stages: the site is being built/prepared, NOT yet live. A\n * Launch report (recipes/launch.ts) flips Status → \"maintenance\" at go-live\n * (updateLaunched), so \"maintenance\" is the true live state. Pre-launch sites must\n * not be audited as production (their deploy/domain/uptime/CMS audits fail because\n * nothing is live yet) nor scheduled recurring Maintenance/Testing reports.\n */\nexport const PRE_LAUNCH_STATUSES: ReadonlySet<Status> = new Set<Status>([\n \"in development\",\n \"launch period\",\n]);\n\nexport function isPreLaunch(status: Status | null): boolean {\n return status !== null && PRE_LAUNCH_STATUSES.has(status);\n}\n\n/** Every Status value the code recognizes. Typed ReadonlySet<string> so a\n * blind-cast typo'd cell can be probed without a cast. */\nexport const KNOWN_STATUSES: ReadonlySet<string> = new Set<Status>([\n \"in development\",\n \"launch period\",\n \"maintenance\",\n \"hosting\",\n \"probably not our problem\",\n \"deprecated\",\n \"legacy\",\n]);\n\n/** Terminal, out-of-fleet lifecycle states: kept in Airtable for the record,\n * excluded from every fleet op (sweeps, reports, audits, cockpit tiers) exactly\n * as before, but surfaced on the cockpit as an archived lane so a row can never\n * silently vanish. */\nexport const ARCHIVED_STATUSES: ReadonlySet<Status> = new Set<Status>([\"legacy\", \"deprecated\"]);\n\nexport function isArchivedStatus(status: Status | null): boolean {\n return status !== null && ARCHIVED_STATUSES.has(status);\n}\n\n/** True when the Status cell holds a value outside the code's union (typo /\n * renamed option / stray whitespace). mapRow deliberately does NOT null these —\n * due.ts/preflight.ts treat a null status as eligible-by-default, so nulling a\n * typo would ACTIVATE the row. The cockpit surfaces these as watch rows instead. */\nexport function isUnrecognizedStatus(status: Status | null): boolean {\n return status !== null && !KNOWN_STATUSES.has(status);\n}\n\nconst FREQUENCIES: readonly Frequency[] = [\"None\", \"Monthly\", \"Quarterly\", \"Yearly\"];\n\n/** Coerce an Airtable single-select value to a known Frequency at the read boundary.\n * Whitespace is trimmed first, so an operator's trailing-space option (\"Quarterly \")\n * still schedules instead of silently unscheduling the site. Any other non-empty,\n * unrecognized value — a renamed / typo'd option — warns LOUDLY and falls back to\n * \"None\" (its section is simply omitted) rather than flowing a bogus string downstream,\n * which the announcement would otherwise render as \"We do this undefined.\" into a\n * client email. Blank/undefined is a silent \"None\": no schedule is intentional. */\nfunction toFrequency(raw: unknown, context: string): Frequency {\n if (typeof raw !== \"string\") return \"None\";\n const trimmed = raw.trim();\n if ((FREQUENCIES as readonly string[]).includes(trimmed)) return trimmed as Frequency;\n if (trimmed !== \"\") {\n console.warn(\n `⚠ ${context}: unrecognized frequency '${raw}' — treating as None (not scheduling); fix the Airtable value`,\n );\n }\n return \"None\";\n}\n\n/** Coerce an Airtable tri-state single-select verdict cell (`pass`/`fail`/blank) to\n * `\"pass\" | \"fail\" | null`. Any value other than the literal strings \"pass\"/\"fail\" — blank,\n * an unrecognized option, a typo, wrong type — reads as null (\"never ran\"), never guessed. The\n * ONE shared reader for every verdict column of this shape: `Function health`, `CMS Reachable`,\n * `Uptime Reachable`, `Titles & Meta OK` (Plan 2), and reused (not redeclared) by Plan 3's\n * `Smoke OK` / `Form E2E OK` read-backs. */\nexport function toVerdict(raw: unknown): \"pass\" | \"fail\" | null {\n return raw === \"pass\" || raw === \"fail\" ? raw : null;\n}\n\n// NOTE: every `f[\"...\"]` key below is a load-bearing magic string that must match\n// the live Airtable \"Websites\" column name EXACTLY — including the legacy\n// misspelling `\"maintenence freq\"`, the mixed-case `\"GA4 property ID\"`, and the\n// lowercase `\"url\"` / `\"point of contact\"`. A column rename in Airtable silently\n// returns undefined here (→ null), which degrades quietly (GA skipped, recipients\n// empty) with no error. If you rename a column, change it here too.\nexport function mapRow(rec: { id: string; fields: Record<string, unknown> }): WebsiteRow {\n const f = rec.fields;\n const name = String(f[\"Name\"] ?? \"\");\n const attachments =\n (f[\"Header image\"] as Array<{ url: string; filename: string; type: string }> | undefined) ?? [];\n const header = attachments[0] ?? null;\n return {\n id: rec.id,\n name,\n url: String(f[\"url\"] ?? \"\"),\n status: (f[\"Status\"] as Status | undefined) ?? null,\n pointOfContact: (f[\"point of contact\"] as string | undefined) ?? null,\n maintenanceFreq: toFrequency(f[\"maintenence freq\"], `${name} maintenance`),\n testingFreq: toFrequency(f[\"testing freq\"], `${name} testing`),\n maintenanceFreqRaw: (f[\"maintenence freq\"] as string | undefined) ?? null,\n testingFreqRaw: (f[\"testing freq\"] as string | undefined) ?? null,\n maintenanceDay: (f[\"maintenance day\"] as string | undefined) ?? null,\n testingDay: (f[\"testing day\"] as string | undefined) ?? null,\n ga4PropertyId: (f[\"GA4 property ID\"] as string | undefined) ?? null,\n searchQuery: (f[\"Search query\"] as string | undefined) ?? null,\n searchConsoleProperty: (f[\"Search Console property\"] as string | undefined) ?? null,\n analyticsSoftFailAt: (f[\"Analytics soft-fail at\"] as string | undefined) ?? null,\n gitRepo: (f[\"Git repo\"] as string | undefined) ?? null,\n reportRecipientsTo: (f[\"Report recipients (To)\"] as string | undefined) ?? null,\n reportRecipientsCc: (f[\"Report recipients (CC)\"] as string | undefined) ?? null,\n // Tolerate BOTH the current Multiple-Select array shape AND a delimited long-text\n // string (comma/newline separated), so the field can migrate to a plain text column\n // with no code change here. Trim + drop empties either way. The array branch also\n // validates ELEMENT types: a collaborator/attachment-shaped field passes\n // Array.isArray with OBJECT elements, and an unchecked cast would make assignTier's\n // `.trim()` throw — one misconfigured row must not 500 the whole cockpit build.\n acceptedWatchConditions: Array.isArray(f[\"Accepted Watch Conditions\"])\n ? (f[\"Accepted Watch Conditions\"] as unknown[])\n .filter((x): x is string => typeof x === \"string\")\n .map((s) => s.trim())\n .filter(Boolean)\n : typeof f[\"Accepted Watch Conditions\"] === \"string\"\n ? (f[\"Accepted Watch Conditions\"] as string)\n .split(/[\\n,]/)\n .map((s) => s.trim())\n .filter(Boolean)\n : [],\n headerImage: header,\n pScore: (f[\"pScore\"] as number | undefined) ?? null,\n rScore: (f[\"rScore\"] as number | undefined) ?? null,\n bpScore: (f[\"bpScore\"] as number | undefined) ?? null,\n seoScore: (f[\"seoScore\"] as number | undefined) ?? null,\n lastLighthouseAuditAt: (f[\"Last lighthouse audit at\"] as string | undefined) ?? null,\n a11yViolations: (f[\"A11y Violations\"] as number | undefined) ?? null,\n depsDrifted: (f[\"Deps Drifted\"] as number | undefined) ?? null,\n depsMajorBehind: (f[\"Deps Major Behind\"] as number | undefined) ?? null,\n depsOutdated: (f[\"Deps Outdated\"] as number | undefined) ?? null,\n depsMajorOutdated: (f[\"Deps Major Outdated\"] as number | undefined) ?? null,\n securityVulnsCritical: (f[\"Security Vulns Critical\"] as number | undefined) ?? null,\n securityVulnsHigh: (f[\"Security Vulns High\"] as number | undefined) ?? null,\n securityVulnsModerate: (f[\"Security Vulns Moderate\"] as number | undefined) ?? null,\n securityVulnsLow: (f[\"Security Vulns Low\"] as number | undefined) ?? null,\n securityAutoFixAttempts: (f[\"Security Auto-Fix Attempts\"] as number | undefined) ?? null,\n lastSecurityAuditAt: (f[\"Last security audit at\"] as string | undefined) ?? null,\n securityAdvisories: parseSecurityAdvisories(f[\"Security advisories\"]),\n certDaysRemaining: (f[\"Cert days remaining\"] as number | undefined) ?? null,\n domainCheckedAt: (f[\"Domain checked at\"] as string | undefined) ?? null,\n netlifyId: trimToNull(f[\"Netlify ID\"]),\n deployStatus: (f[\"Deploy status\"] as string | undefined) ?? null,\n lastDeployAt: (f[\"Last deploy at\"] as string | undefined) ?? null,\n deployLogUrl: (f[\"Deploy log URL\"] as string | undefined) ?? null,\n deployCheckedAt: (f[\"Deploy checked at\"] as string | undefined) ?? null,\n functionHealth: toVerdict(f[\"Function health\"]),\n cmsReachable: toVerdict(f[\"CMS Reachable\"]),\n turnstileWidget: toVerdict(f[\"Turnstile widget\"]),\n functionHealthCheckedAt: (f[\"Function health checked at\"] as string | undefined) ?? null,\n crossbrowserOk:\n typeof f[\"Crossbrowser OK\"] === \"boolean\" ? (f[\"Crossbrowser OK\"] as boolean) : null,\n mobileOk: typeof f[\"Mobile OK\"] === \"boolean\" ? (f[\"Mobile OK\"] as boolean) : null,\n linksOk: typeof f[\"Links OK\"] === \"boolean\" ? (f[\"Links OK\"] as boolean) : null,\n brokenLinks: typeof f[\"Broken links\"] === \"number\" ? (f[\"Broken links\"] as number) : null,\n browserCheckedAt: (f[\"Browser checked at\"] as string | undefined) ?? null,\n reachableOk: toVerdict(f[\"Uptime Reachable\"]),\n titleMetaOk: toVerdict(f[\"Titles & Meta OK\"]),\n copyIntro: trimToNull(f[\"Copy — Intro\"]),\n copyContact: trimToNull(f[\"Copy — Contact\"]),\n copyFooter: trimToNull(f[\"Copy — Footer\"]),\n launchedAt: (f[\"Launched at\"] as string | undefined) ?? null,\n newsletterWebhook: trimToNull(f[\"Newsletter Webhook\"]),\n notifyRouting: parseNotifyRouting(f[\"Notify Routing\"]),\n mailchimpApiKey: trimToNull(f[\"Mailchimp API Key\"]),\n mailchimpAudienceId: trimToNull(f[\"Mailchimp Audience ID\"]),\n // Boolean guard like crossbrowserOk, but defaults FALSE (not null) when absent: an\n // unset/unknown column must read as \"not required\" so the feature ships dark.\n requireTurnstile:\n typeof f[\"Require Turnstile\"] === \"boolean\" ? (f[\"Require Turnstile\"] as boolean) : false,\n renovateFailingCis: (f[\"Renovate Failing CIs\"] as number | undefined) ?? null,\n defaultBranchCi: (f[\"Default Branch CI\"] as string | undefined) ?? null,\n lastCommitAt: (f[\"Last Commit At\"] as string | undefined) ?? null,\n githubSignalsAt: (f[\"GitHub Signals At\"] as string | undefined) ?? null,\n smokeOk: toVerdict(f[\"Smoke OK\"]),\n lastSmokeAt: (f[\"Last Smoke At\"] as string | undefined) ?? null,\n formE2eOk: toVerdict(f[\"Form E2E OK\"]),\n formE2eCheckedAt: (f[\"Form E2E checked at\"] as string | undefined) ?? null,\n };\n}\n\nexport async function listWebsites(base: AirtableBase): Promise<WebsiteRow[]> {\n const out: WebsiteRow[] = [];\n await base(WEBSITES_TABLE)\n .select({ pageSize: 100 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return out;\n}\n\nexport async function getWebsiteBySlug(\n base: AirtableBase,\n slug: string,\n): Promise<WebsiteRow | null> {\n // Slugs are siteSlug() output: [a-z0-9] segments joined by single hyphens.\n // Reject anything else — it can't match a real row, and it keeps URL-supplied\n // input out of the filter formula below (formula-injection guard).\n if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) return null;\n\n // Narrow the fetch to the slug-matching row server-side instead of paging the\n // whole table per request (MEDIUM-H). The formula replicates siteSlug() on\n // {Name} — lowercase → non-alnum runs to \"-\" → strip leading/trailing \"-\" —\n // verified against the live base. maxRecords caps it (slug collisions keep the\n // prior first-match-wins behavior).\n const formula = `REGEX_REPLACE(REGEX_REPLACE(LOWER({Name}),\"[^a-z0-9]+\",\"-\"),\"^-|-$\",\"\")=${JSON.stringify(\n slug,\n )}`;\n const rows: WebsiteRow[] = [];\n await base(WEBSITES_TABLE)\n .select({ filterByFormula: formula, maxRecords: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n // Confirm the match in JS too: keeps the function correct if the formula and\n // siteSlug() ever drift, and under test fakes that don't evaluate the formula.\n return rows.find((w) => siteSlug(w.name) === slug) ?? null;\n}\n\n// ── audit-field builders ─────────────────────────────────────────────────────\n// One source of truth for the column-name → value mappings of each audit type.\n// The per-audit `updateXxxCounts` writers delegate to these (for their other\n// callers), and `updateAuditFields` merges whichever are present into ONE write —\n// so the field-name magic strings live in exactly one place.\n\nexport type A11yCounts = { violations: number };\nexport type DepsCounts = {\n drifted: number;\n majorBehind: number;\n outdated: number | null;\n majorOutdated: number | null;\n};\nexport type SecurityCounts = { critical: number; high: number; moderate: number; low: number };\nexport type Severity = \"low\" | \"moderate\" | \"high\" | \"critical\";\n/** One known vulnerability behind the security counts, as persisted/rendered. */\nexport type SecurityAdvisory = {\n module: string;\n severity: Severity;\n title: string;\n cves: string[];\n url: string | null;\n /** GitHub Dependabot dependency scope (\"runtime\" | \"development\"), when known. Lets the\n * dashboard flag build-time-only (\"development\") vulns. Omitted for advisories from the\n * lockfile `pnpm audit` fallback, which carries no per-package graph scope. */\n scope?: \"runtime\" | \"development\";\n /** GitHub Dependabot dependency-graph relationship, when known. \"transitive\" means no\n * direct-dep bump can fix it — Renovate's vuln alerts have no fix vehicle until the weekly\n * lockfile-maintenance window. Omitted when GitHub reports \"unknown\" or for the\n * `pnpm audit` fallback. Drives {@link allActionableVulnsTransitive}. */\n relationship?: \"direct\" | \"transitive\";\n};\nexport type DomainResult = { certDaysRemaining: number | null; checkedAt: string };\nexport type NetlifyDeployResult = {\n /** Netlify deploy state lower-cased (`ready`/`error`/`building`/…), null = none. */\n state: string | null;\n /** When the latest production deploy was published/created (ISO), null = unknown. */\n deployedAt: string | null;\n /** Link to the deploy / its build log, null = unknown. */\n logUrl: string | null;\n /** When the audit last ran. */\n checkedAt: string;\n};\nexport type BrowserAuditFields = {\n desktopOk: boolean;\n mobileOk: boolean;\n linksOk: boolean;\n reachableOk: boolean;\n titleMetaOk: boolean;\n brokenLinks: number;\n checkedAt: string;\n};\nexport type FunctionHealthResult = {\n /** `pass` when `/health` answered `ok:true`, else `fail`. Never null — the audit only produces a\n * result when it ran (a self-skip carries no details, so this extractor isn't reached). */\n functionHealth: \"pass\" | \"fail\";\n /** From the same body's `prismic` sub-status (R2.2): `\"ok\"` → `\"pass\"`, `\"error\"` → `\"fail\"`.\n * Anything else (`\"skipped\"` — a placeholder repo with no live Prismic — or a raw `null`, e.g.\n * the synthetic \"deployed but erroring\" body) → `null`: the CMS probe never actually ran, so it\n * must NOT red CMS reachability for a site that simply hasn't wired Prismic yet. */\n cmsReachable: \"pass\" | \"fail\" | null;\n /** From the same body's `forms.turnstile` boolean: `true` → `\"pass\"`, `false` → `\"fail\"`.\n * A missing/null `forms` block (older site package, or the synthetic \"deployed but\n * erroring\" body) → `null` — the widget state is simply unknown, not a failure. */\n turnstileWidget: \"pass\" | \"fail\" | null;\n /** When the audit ran (freshness stamp for all three verdicts). */\n checkedAt: string;\n};\n\nexport type SmokeResult = { ok: \"pass\" | \"fail\"; checkedAt: string };\n\n/** `ok` null clears the single-select cell (n/a — no contact form); a fresh\n * `checkedAt` still stamps the row so Plan 4 reads null+fresh as n/a. */\nexport type FormE2eResult = { ok: \"pass\" | \"fail\" | null; checkedAt: string };\n\nfunction scoreFields(scores: LighthouseScoreWriteback): FieldSet {\n // A null score CLEARS the cell (→ dashboard \"—\"), distinguishing a metric that\n // errored this run (e.g. NO_LCP → null performance) from a real low score. This\n // intentionally overwrites a prior value: a run that couldn't measure the metric\n // shouldn't keep showing a stale number. Writing null to clear mirrors\n // updateAnalyticsHealth; the `as FieldSet` cast is needed because airtable's\n // FieldSet type omits null.\n const fields: Record<string, number | string | null> = {\n pScore: scores.performance,\n rScore: scores.accessibility,\n bpScore: scores.bestPractices,\n seoScore: scores.seo,\n \"Last lighthouse audit at\": new Date().toISOString(),\n };\n return fields as FieldSet;\n}\n\nfunction a11yFields(counts: A11yCounts): FieldSet {\n return { \"A11y Violations\": counts.violations };\n}\n\nfunction depsFields(counts: DepsCounts): FieldSet {\n const fields: FieldSet = {\n \"Deps Drifted\": counts.drifted,\n \"Deps Major Behind\": counts.majorBehind,\n };\n // Only write the outdated count when it was determined — a null (no/stale\n // lockfile this run) must not clobber a previously-good value.\n if (counts.outdated !== null) {\n fields[\"Deps Outdated\"] = counts.outdated;\n }\n // Same null-guard for the registry-major breakdown (same source signal).\n if (counts.majorOutdated !== null) {\n fields[\"Deps Major Outdated\"] = counts.majorOutdated;\n }\n return fields;\n}\n\n/** Critical first → low last; the persist cap and the dashboard list both lean on this. */\nexport const SEVERITY_RANK: Record<Severity, number> = {\n critical: 0,\n high: 1,\n moderate: 2,\n low: 3,\n};\n/** Cap persisted advisories so the JSON field can't blow up on a badly-neglected site. */\nconst MAX_PERSISTED_ADVISORIES = 25;\n\n/** Coerce one untrusted value into a SecurityAdvisory, or null if it isn't shaped like one.\n * Shared by the read path (`parseSecurityAdvisories`) and the write path so both validate\n * identically. A missing module or unrecognized severity drops the entry entirely. */\nexport function normalizeSecurityAdvisory(raw: unknown): SecurityAdvisory | null {\n if (!raw || typeof raw !== \"object\") return null;\n const e = raw as Record<string, unknown>;\n const module = typeof e[\"module\"] === \"string\" ? (e[\"module\"] as string) : null;\n const severity = e[\"severity\"];\n if (module === null) return null;\n if (\n severity !== \"low\" &&\n severity !== \"moderate\" &&\n severity !== \"high\" &&\n severity !== \"critical\"\n )\n return null;\n const cves = Array.isArray(e[\"cves\"])\n ? (e[\"cves\"] as unknown[]).filter((c): c is string => typeof c === \"string\")\n : [];\n const scope =\n e[\"scope\"] === \"runtime\" || e[\"scope\"] === \"development\"\n ? (e[\"scope\"] as \"runtime\" | \"development\")\n : undefined;\n const relationship =\n e[\"relationship\"] === \"direct\" || e[\"relationship\"] === \"transitive\"\n ? (e[\"relationship\"] as \"direct\" | \"transitive\")\n : undefined;\n return {\n module,\n severity,\n title: typeof e[\"title\"] === \"string\" ? (e[\"title\"] as string) : \"\",\n cves,\n url: typeof e[\"url\"] === \"string\" ? (e[\"url\"] as string) : null,\n ...(scope ? { scope } : {}),\n ...(relationship ? { relationship } : {}),\n };\n}\n\n/**\n * True iff the persisted advisory detail proves EVERY actionable (critical/high) vuln is a\n * TRANSITIVE dependency — i.e. Renovate's vulnerability alerts have no direct-dep bump to\n * open, and the fix rides the weekly lockfile-maintenance window instead. Deliberately\n * conservative: `null` detail (never audited / `pnpm audit` fallback), an advisory missing\n * its `relationship` (unknown / pre-existing JSON), or a counts-vs-detail mismatch (no\n * critical/high advisory in the list) all return false — missing data must never mute the\n * auto-fix-failed escalation. Consumed by the attempts counter (don't count a known no-op\n * dispatch as a failed fix attempt) and the digest (say \"transitive-only\", not\n * \"auto-fix failed\"). The persist cap keeps advisories sorted critical-first, so\n * critical/high entries are never the ones capped away.\n */\nexport function allActionableVulnsTransitive(advisories: SecurityAdvisory[] | null): boolean {\n if (!advisories) return false;\n const actionable = advisories.filter((a) => a.severity === \"critical\" || a.severity === \"high\");\n if (actionable.length === 0) return false;\n return actionable.every((a) => a.relationship === \"transitive\");\n}\n\n/** Parse the `Security advisories` JSON cell. null when absent/blank/unparseable/not-an-array\n * (treated as \"never audited\"); a valid array (possibly empty = audited clean) otherwise. */\nexport function parseSecurityAdvisories(raw: unknown): SecurityAdvisory[] | null {\n if (typeof raw !== \"string\" || raw.trim() === \"\") return null;\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return null;\n }\n if (!Array.isArray(parsed)) return null;\n return parsed.map(normalizeSecurityAdvisory).filter((a): a is SecurityAdvisory => a !== null);\n}\n\nfunction securityAdvisoryFields(advisories: SecurityAdvisory[]): FieldSet {\n const capped = [...advisories]\n .sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])\n .slice(0, MAX_PERSISTED_ADVISORIES);\n // Always write — an empty array (\"[]\") clears a stale list when a once-vulnerable site goes clean.\n return { \"Security advisories\": JSON.stringify(capped) };\n}\n\nfunction securityFields(counts: SecurityCounts): FieldSet {\n return {\n \"Security Vulns Critical\": counts.critical,\n \"Security Vulns High\": counts.high,\n \"Security Vulns Moderate\": counts.moderate,\n \"Security Vulns Low\": counts.low,\n // Stamp freshness alongside the counts so the Security Updates auto-tick can require a recent\n // audit (a clean count from months ago must not silently keep ticking the box).\n \"Last security audit at\": new Date().toISOString(),\n };\n}\n\nfunction domainFields(result: DomainResult): FieldSet {\n const fields: FieldSet = { \"Domain checked at\": result.checkedAt };\n // Write the cert days UNCONDITIONALLY: a null (unresolved / no usable cert) must CLEAR any\n // previously-good value. Leaving a stale number in place — next to a freshly-stamped \"Domain\n // checked at\" — false-passes the Domain/DNS/SSL auto-tick (domainEvidence reads the stale\n // non-null value as a current pass) for a site that's actually down. null clears the cell in\n // Airtable; FieldSet's type doesn't model null, hence the cast through a widened record.\n (fields as Record<string, number | null>)[\"Cert days remaining\"] = result.certDaysRemaining;\n return fields;\n}\n\nfunction netlifyDeployFields(result: NetlifyDeployResult): FieldSet {\n // Write all three deploy fields UNCONDITIONALLY (null clears the cell): a null state /\n // deployedAt / logUrl this run means \"couldn't read it\" and must not leave a STALE value\n // sitting next to a fresh \"Deploy checked at\" — the dashboard would otherwise render a green\n // \"ready\" badge for a site whose latest deploy actually errored. FieldSet's type doesn't model\n // null, hence the cast through a widened record (same approach as domainFields).\n const fields: Record<string, string | null> = {\n \"Deploy status\": result.state,\n \"Last deploy at\": result.deployedAt,\n \"Deploy log URL\": result.logUrl,\n \"Deploy checked at\": result.checkedAt,\n };\n return fields as FieldSet;\n}\n\nfunction browserFields(r: BrowserAuditFields): FieldSet {\n return {\n \"Crossbrowser OK\": r.desktopOk,\n \"Mobile OK\": r.mobileOk,\n \"Links OK\": r.linksOk,\n \"Broken links\": r.brokenLinks,\n \"Browser checked at\": r.checkedAt,\n // NEW tri-state single-select verdicts (empty = never ran). The browser audit only produces a\n // BrowserAuditFields when it actually ran (hasBrowserResult guards on checkedAt), so each verdict\n // is always a concrete boolean here — serialize true→\"pass\", false→\"fail\". The existing boolean\n // columns above are deliberately NOT retrofitted (out of scope).\n \"Uptime Reachable\": r.reachableOk ? \"pass\" : \"fail\",\n \"Titles & Meta OK\": r.titleMetaOk ? \"pass\" : \"fail\",\n };\n}\n\nfunction functionHealthFields(r: FunctionHealthResult): FieldSet {\n // \"CMS Reachable\" is written UNCONDITIONALLY (null clears the cell): the audit only supplies a\n // result when it ran, but `cmsReachable` itself can legitimately be null this run (R2.2 — the CMS\n // probe never actually happened, e.g. a placeholder repo with no live Prismic). Clearing rather\n // than leaving a stale pass/fail sitting next to a freshly-stamped checked-at is what keeps a\n // placeholder site from showing a stale CMS verdict. FieldSet's type doesn't model null, hence the\n // cast through a widened record (same approach as domainFields/netlifyDeployFields).\n // \"Function health\" is never null — the audit only writes when it ran, so it's always a concrete\n // pass/fail. Written SEPARATELY from \"Deploy status\" so the Netlify build state keeps its own\n // meaning.\n const fields: Record<string, string | null> = {\n \"Function health\": r.functionHealth,\n \"CMS Reachable\": r.cmsReachable,\n // Same null-clears semantics as \"CMS Reachable\": a body without a forms block means the\n // widget state is unknown THIS run — clearing beats a stale verdict next to a fresh stamp.\n \"Turnstile widget\": r.turnstileWidget,\n \"Function health checked at\": r.checkedAt,\n };\n return fields as FieldSet;\n}\n\nfunction smokeFields(r: SmokeResult): FieldSet {\n // The verdict is stored as the literal single-select option (\"pass\"/\"fail\"), so\n // no boolean→string coercion is needed. A skip never reaches here (it produces no\n // SmokeResult), so this column is only ever written with a concrete verdict.\n return { \"Smoke OK\": r.ok, \"Last Smoke At\": r.checkedAt };\n}\n\nfunction formE2eFields(r: FormE2eResult): FieldSet {\n // `ok` is already the single-select value (\"pass\"/\"fail\") or null. Writing null\n // CLEARS the cell (→ n/a, distinguished from \"never ran\" by the fresh checked-at\n // stamped alongside). FieldSet's type omits null, hence the widened-record cast\n // (same approach as domainFields / netlifyDeployFields).\n const fields: Record<string, string | null> = {\n \"Form E2E OK\": r.ok,\n \"Form E2E checked at\": r.checkedAt,\n };\n return fields as FieldSet;\n}\n\n/**\n * Write the four Lighthouse scores + a refreshed-at timestamp onto a Websites row.\n * Called by `audit lighthouse --write-airtable` after a successful audit run, so\n * the operator never has to paste numbers manually before drafting a report.\n */\nexport async function updateScores(\n base: AirtableBase,\n recordId: string,\n scores: LighthouseScores,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: scoreFields(scores) }]);\n}\n\n/**\n * Record (or clear) the per-site GA/Search enrichment health on the `Analytics\n * soft-fail at` column. `at` is an ISO timestamp when the site's last draft had a\n * GA/Search soft-failure, or `null` to clear it after a clean enrichment. The\n * caller (drafting) swallows errors: this column is operator-added, so until it\n * exists Airtable throws UNKNOWN_FIELD_NAME — which must not break a draft.\n */\nexport async function updateAnalyticsHealth(\n base: AirtableBase,\n recordId: string,\n at: string | null,\n): Promise<void> {\n const fields: Record<string, string | null> = { \"Analytics soft-fail at\": at };\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: fields as FieldSet }]);\n}\n\n/** Persist a11y violation count. */\nexport async function updateA11yCounts(\n base: AirtableBase,\n recordId: string,\n counts: A11yCounts,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: a11yFields(counts) }]);\n}\n\n/** Persist deps drift counts (declared-range drift + real outdated installs). */\nexport async function updateDepsCounts(\n base: AirtableBase,\n recordId: string,\n counts: DepsCounts,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: depsFields(counts) }]);\n}\n\n/** Persist security vulnerability counts by severity. */\nexport async function updateSecurityCounts(\n base: AirtableBase,\n recordId: string,\n counts: SecurityCounts,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: securityFields(counts) }]);\n}\n\n/** Persist a site's auto-fix attempt counter. Its own one-field writer so the\n * nightly Renovate dispatch can update it without touching the audit's counts. */\nexport async function updateAutoFixAttempts(\n base: AirtableBase,\n recordId: string,\n attempts: number,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([\n { id: recordId, fields: { \"Security Auto-Fix Attempts\": attempts } },\n ]);\n}\n\n/**\n * Persist the code-computed next-due dates (date-only `YYYY-MM-DD`, or `null` to\n * clear) for the maintenance + testing schedules. Owned by the nightly `--due` sweep\n * so the \"next\" dates shown in Airtable come from the SAME logic as the scheduler\n * (`nextDueDate`) — no Airtable-side formula or automation. Best-effort at the call\n * site: the `Next … at` columns are operator-added, so until they exist Airtable\n * throws UNKNOWN_FIELD_NAME, which must not break the nightly draft run.\n */\nexport async function updateNextDueDates(\n base: AirtableBase,\n recordId: string,\n dates: { maintenanceAt: string | null; testingAt: string | null },\n): Promise<void> {\n const fields: Record<string, string | null> = {\n \"Next maintenance at\": dates.maintenanceAt,\n \"Next testing at\": dates.testingAt,\n };\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: fields as FieldSet }]);\n}\n\n/** Generic single-field writer for the dashboard site-details editor. The caller\n * (setSiteDetail) restricts `column` to the EDITABLE_SITE_FIELDS allowlist, so this\n * never writes an arbitrary column from request input. */\nexport async function updateSiteField(\n base: AirtableBase,\n recordId: string,\n column: string,\n value: string,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: { [column]: value } }]);\n}\n\n/**\n * Persist all of a single audit run's results to one Websites row in ONE atomic\n * `update()` — instead of up to four sequential updates on the same id (which left\n * a row half-written on a mid-sequence failure and quadrupled the request volume).\n * Pass only the audit slices that produced real values; each present slice is merged\n * via the SAME field mappings the per-audit writers use. Omit a slice (or pass\n * undefined) to leave those columns untouched. Returns the merged FieldSet so the\n * caller can enumerate what was written.\n */\nexport async function updateAuditFields(\n base: AirtableBase,\n recordId: string,\n audits: {\n scores?: LighthouseScoreWriteback;\n a11y?: A11yCounts;\n deps?: DepsCounts;\n security?: SecurityCounts;\n securityAdvisories?: SecurityAdvisory[];\n domain?: DomainResult;\n browser?: BrowserAuditFields;\n netlifyDeploy?: NetlifyDeployResult;\n functionHealth?: FunctionHealthResult;\n smoke?: SmokeResult;\n formE2e?: FormE2eResult;\n },\n): Promise<FieldSet> {\n const fields: FieldSet = {};\n if (audits.scores) Object.assign(fields, scoreFields(audits.scores));\n if (audits.a11y) Object.assign(fields, a11yFields(audits.a11y));\n if (audits.deps) Object.assign(fields, depsFields(audits.deps));\n if (audits.security) Object.assign(fields, securityFields(audits.security));\n // Separate slice (not folded into `security`) so the advisory list and the counts can be\n // written independently, but in practice the security audit supplies both together.\n if (audits.securityAdvisories)\n Object.assign(fields, securityAdvisoryFields(audits.securityAdvisories));\n if (audits.domain) Object.assign(fields, domainFields(audits.domain));\n if (audits.browser) Object.assign(fields, browserFields(audits.browser));\n if (audits.netlifyDeploy) Object.assign(fields, netlifyDeployFields(audits.netlifyDeploy));\n if (audits.functionHealth) Object.assign(fields, functionHealthFields(audits.functionHealth));\n if (audits.smoke) Object.assign(fields, smokeFields(audits.smoke));\n if (audits.formE2e) Object.assign(fields, formE2eFields(audits.formE2e));\n await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);\n return fields;\n}\n\n/** Persist the GitHub-signals sweep onto a Websites row (slice 2a). A null\n * `lastCommitAt` is OMITTED so a not-determined-this-run value never clobbers a\n * previously-good timestamp (mirrors updateDepsCounts' outdated handling). */\nexport async function updateGitHubSignals(\n base: AirtableBase,\n recordId: string,\n signals: {\n renovateFailingCis: number;\n ciState: string;\n lastCommitAt: string | null;\n sweptAt: string;\n },\n): Promise<void> {\n const fields: FieldSet = {\n \"Renovate Failing CIs\": signals.renovateFailingCis,\n \"Default Branch CI\": signals.ciState,\n \"GitHub Signals At\": signals.sweptAt,\n };\n if (signals.lastCommitAt !== null) {\n fields[\"Last Commit At\"] = signals.lastCommitAt;\n }\n await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);\n}\n\n/** Mark a site launched: flip Status → maintenance + stamp Launched at (M6b).\n * The first code that writes Status. Called after a Launch report sends. */\nexport async function updateLaunched(\n base: AirtableBase,\n recordId: string,\n at: string,\n): Promise<void> {\n const fields: FieldSet = { Status: \"maintenance\", \"Launched at\": at };\n await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/reports/digest.ts"],"sourcesContent":["// src/reports/digest.ts\nimport { openBase, readAirtableConfig, type AirtableBase } from \"./airtable/client.js\";\nimport { listAllReports, isPendingApproval } from \"./airtable/reports.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport { listWebsites, siteSlug, type WebsiteRow } from \"./airtable/websites.js\";\nimport { defaultResendClient, type ResendClient } from \"./send/resend.js\";\nimport { isIdempotencyConflict } from \"./send/idempotency.js\";\nimport {\n collectVulnAlerts,\n collectDeliveryFailures,\n collectPreflightBlocked,\n collectLighthouseAlerts,\n collectRenovateAlerts,\n collectCiAlerts,\n collectAnalyticsFailures,\n collectTurnstileGuardrailAlerts,\n collectNotifyBounceAlerts,\n NOTIFY_BOUNCE_WINDOW_DAYS,\n} from \"../alerts/digest-collectors.js\";\nimport { diffAttention, readDigestState, writeDigestState } from \"../alerts/digest-state.js\";\nimport { escapeHtml as esc } from \"../util/html.js\";\nimport type {\n AttentionItem,\n AttentionSeverity,\n AttentionStatus,\n ReadyItem,\n DigestSections,\n SubmissionsDigestSection,\n} from \"../alerts/attention.js\";\n// Type-only: erased at compile time, so the kysely/libsql devDependency never loads\n// in a consuming fleet site (the runtime read is the dynamic import in\n// fetchSubmissionsDigestCounts, same rule as fetchNotifyBounceCounts).\nimport type { SiteSubmissionCounts } from \"../db/submissions.js\";\n\n// The attention/digest contract lives in `../alerts/attention.ts` (a dependency-free\n// types module) so the `alerts/*` collectors can depend on it without importing back\n// from this renderer/IO module — see attention.ts for the cycle it breaks. Re-exported\n// here so existing `from \"./digest.js\"` type importers keep resolving.\nexport type {\n AttentionItem,\n AttentionSeverity,\n AttentionStatus,\n ReadyItem,\n DigestSections,\n SubmissionsDigestSection,\n} from \"../alerts/attention.js\";\n\nconst GREY = \"#757575\";\nconst RED = \"#C00\";\n\n/** Shared anchor style — Gmail does not inherit font-family into <a> tags. */\nconst ANCHOR_STYLE = `color:${RED};font-family:helvetica,sans-serif`;\n\nfunction readySection(items: ReadyItem[]): string {\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Ready for your yes</h2>`;\n if (items.length === 0) {\n return `${heading}<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">Nothing waiting on you.</p>`;\n }\n const rows = items\n .map((it) => {\n const safeUrl = it.dashboardUrl.startsWith(\"https://\") ? it.dashboardUrl : undefined;\n const link = safeUrl\n ? `<a href=\"${esc(safeUrl)}\" style=\"${ANCHOR_STYLE}\">review & approve</a>`\n : `review & approve`;\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">\n <strong style=\"color:#222\">${esc(it.siteName)}</strong> — ${esc(it.reportType)} (${esc(it.period)})\n — ${link}\n </td>\n </tr>`;\n })\n .join(\"\");\n return `${heading}<table role=\"presentation\" style=\"border-collapse:collapse;margin:0\">${rows}</table>`;\n}\n\nconst SEVERITY_ORDER: Record<AttentionSeverity, number> = { critical: 0, warning: 1 };\n\n/** Render the per-item status badge (\"NEW\"/\"WORSE\"); standing items get nothing. */\nfunction attentionBadge(status?: AttentionStatus): string {\n if (status === \"new\")\n return `<strong style=\"color:${RED};font-family:helvetica,sans-serif\">NEW</strong> `;\n if (status === \"worse\")\n return `<strong style=\"color:${RED};font-family:helvetica,sans-serif\">WORSE</strong> `;\n return \"\";\n}\n\nfunction attentionSection(items: AttentionItem[]): string {\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Needs attention</h2>`;\n if (items.length === 0) {\n return `${heading}<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">All clear — nothing needs attention.</p>`;\n }\n\n // Group by siteName, preserving first-seen site order; sort within a site by\n // severity (critical first).\n const bySite = new Map<string, AttentionItem[]>();\n for (const it of items) {\n const bucket = bySite.get(it.siteName);\n if (bucket) bucket.push(it);\n else bySite.set(it.siteName, [it]);\n }\n\n const groups = [...bySite.entries()]\n .map(([siteName, siteItems]) => {\n const sorted = [...siteItems].sort(\n (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity],\n );\n const rows = sorted\n .map((it) => {\n const safeUrl = it.url?.startsWith(\"https://\") ? it.url : undefined;\n const titleHtml = safeUrl\n ? `<a href=\"${esc(safeUrl)}\" style=\"${ANCHOR_STYLE}\">${esc(it.title)}</a>`\n : esc(it.title);\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">${attentionBadge(it.status)}${titleHtml}</td>\n </tr>`;\n })\n .join(\"\");\n return `\n <tr>\n <td style=\"color:#222;font-family:helvetica,sans-serif;font-size:16px;font-weight:700;padding:8px 0 4px\">${esc(siteName)}</td>\n </tr>\n ${rows}`;\n })\n .join(\"\");\n\n return `${heading}<table role=\"presentation\" style=\"border-collapse:collapse;margin:0\">${groups}</table>`;\n}\n\n/** The \"Submissions (24h)\" telemetry block: fleet totals + a per-site glance line.\n * Context, not an alarm — absent/null section or all-zero totals render nothing\n * (no-noise), and the section never triggers a send on its own (see runDigest). */\nfunction submissionsSection(s: SubmissionsDigestSection | null | undefined): string {\n if (!s) return \"\";\n if (s.leads === 0 && s.signups === 0 && s.spamAuto === 0) return \"\";\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Submissions (24h)</h2>`;\n // All three terms always render together — omitting a zero term next to a nonzero\n // one reads as \"not measured\" rather than \"none\"; simplest honest form.\n const totals = [\n `${s.leads} new lead${s.leads === 1 ? \"\" : \"s\"}`,\n `${s.signups} newsletter/RSVP signup${s.signups === 1 ? \"\" : \"s\"}`,\n `${s.spamAuto} auto-filtered spam`,\n ].join(\" · \");\n const totalLine = `<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">${totals}</p>`;\n if (s.bySite.length === 0) return `${heading}${totalLine}`;\n const rows = s.bySite\n .map((site) => {\n const parts: string[] = [];\n if (site.leads > 0) parts.push(`${site.leads} lead${site.leads === 1 ? \"\" : \"s\"}`);\n if (site.signups > 0) parts.push(`${site.signups} signup${site.signups === 1 ? \"\" : \"s\"}`);\n if (site.spamAuto > 0) parts.push(`${site.spamAuto} auto-filtered`);\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">\n <strong style=\"color:#222\">${esc(site.siteName)}</strong> — ${parts.join(\" · \")}\n </td>\n </tr>`;\n })\n .join(\"\");\n return `${heading}${totalLine}<table role=\"presentation\" style=\"border-collapse:collapse;margin:8px 0 0\">${rows}</table>`;\n}\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\n/** Single-operator fleet — fallback when OPERATOR_EMAIL is unset. */\nconst DIGEST_OPERATOR_FALLBACK = \"info@reddoorla.com\";\n\n/** UTC \"YYYY-MM-DD\" — the Resend idempotency key suffix, so a same-day cron re-fire dedupes. */\nfunction digestDateKey(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * The gate for \"Ready for your yes\": Draft ready ∧ ¬Approved to send ∧ Sent at BLANK.\n *\n * Implemented as `listAllReports(base).filter(...)` (the authorized deviation from the plan's\n * draft, which pre-dated Slice 1's merged fix): `listAllReports` already calls `mapRow`, which\n * handles `Period` correctly, so no local `rawToReportRow` duplicate is needed. The JS filter\n * does real work — the test fake does NOT evaluate `filterByFormula` — so correctness is\n * test-provable here.\n *\n * Exported: the fleet homepage (Task 3.5b) reuses it for the pending-approval count.\n */\nexport async function listPendingApproval(base: AirtableBase): Promise<ReportRow[]> {\n return (await listAllReports(base)).filter(isPendingApproval);\n}\n\n// ── collectAttention (IO wrapper, sibling to runDigest) ──────────────────────\n\nexport type CollectAttentionDeps = {\n base: AirtableBase;\n /** Same baseUrl value runDigest threads; used for the /s/<slug> links. */\n baseUrl: string;\n /** Pre-fetched Websites rows. When supplied (runDigest already read them),\n * collectAttention reuses them instead of issuing a second `listWebsites`. */\n websites?: WebsiteRow[];\n /** Pre-fetched Reports rows. When supplied (runDigest already read them),\n * collectAttention reuses them instead of issuing a second `listAllReports`. */\n reports?: ReportRow[];\n /** Clock for the GitHub-signals staleness gate (collectCiAlerts /\n * collectRenovateAlerts skip a >3-day-stale sweep). Defaults to wall-clock;\n * runDigest threads its run-start `today` so a quiet repo's frozen CI/Renovate\n * signal stops badging once its sweep goes stale. */\n now?: Date;\n /** Pre-fetched per-site bounced-lead-notification counts (tests, or a caller with\n * an open db). When omitted, collectAttention reads them from libSQL itself —\n * defensively, so a missing TURSO env or a Turso blip drops just this signal. */\n notifyBounces?: ReadonlyMap<string, number>;\n};\n\n/** Per-site bounced-notification counts for the notify-bounce collector, read from\n * libSQL over the collector's own window. The db modules are imported DYNAMICALLY:\n * src/db/submissions.ts pulls kysely at top level, and kysely/libsql live in\n * devDependencies consuming fleet sites don't install — a static import here would\n * crash their CLI at require time (same rule as openDb's own lazy loads). Any\n * failure (no TURSO env, Turso down) logs and yields an empty map: the digest\n * must never blank over a missing optional signal. */\nasync function fetchNotifyBounceCounts(now: Date): Promise<ReadonlyMap<string, number>> {\n try {\n const [{ openDb, readDbConfig }, { countNotifyBouncedBySite }, { screenOutsSince }] =\n await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/submissions.js\"),\n import(\"../db/screenouts.js\"),\n ]);\n const db = await openDb(readDbConfig());\n return await countNotifyBouncedBySite(db, screenOutsSince(now, NOTIFY_BOUNCE_WINDOW_DAYS));\n } catch (e) {\n console.warn(`⚠ notify-bounce counts unavailable (libSQL): ${(e as Error).message}`);\n return new Map();\n }\n}\n\n/** The \"Submissions (24h)\" telemetry window — a precise 24h ISO-timestamp compare\n * (unlike screenOutsSince's date-only strings). */\nconst SUBMISSIONS_DIGEST_WINDOW_MS = 24 * 60 * 60 * 1000;\n\n/** Per-site submission counts for the digest telemetry section, read from libSQL.\n * Dynamic imports + fail-soft for the same reason as fetchNotifyBounceCounts:\n * kysely/libsql are devDependencies consuming fleet sites don't install, and a\n * Turso blip must drop just this section, never the digest. Returns null (not an\n * empty map) on failure so the renderer can omit the section rather than claim a\n * quiet fleet. */\nasync function fetchSubmissionsDigestCounts(\n now: Date,\n): Promise<ReadonlyMap<string, SiteSubmissionCounts> | null> {\n try {\n const [{ openDb, readDbConfig }, { countSubmissionsSinceBySite }] = await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/submissions.js\"),\n ]);\n const db = await openDb(readDbConfig());\n return await countSubmissionsSinceBySite(\n db,\n new Date(now.getTime() - SUBMISSIONS_DIGEST_WINDOW_MS).toISOString(),\n );\n } catch (e) {\n console.warn(`⚠ submissions telemetry unavailable (libSQL): ${(e as Error).message}`);\n return null;\n }\n}\n\n/** Assemble the \"Submissions (24h)\" section from the per-site counts. PURE (exported\n * for direct tests). Totals sum ALL map entries — an orphan site id still counts in\n * the fleet totals. bySite lists only ids that resolve in `sitesById` AND carry any\n * nonzero count, sorted by total volume desc then siteName A-Z. null counts (libSQL\n * unavailable) → null (section omitted). */\nexport function buildSubmissionsDigestSection(\n counts: ReadonlyMap<string, SiteSubmissionCounts> | null,\n sitesById: ReadonlyMap<string, WebsiteRow>,\n): SubmissionsDigestSection | null {\n if (counts === null) return null;\n let leads = 0;\n let signups = 0;\n let spamAuto = 0;\n const bySite: SubmissionsDigestSection[\"bySite\"] = [];\n for (const [siteId, c] of counts) {\n leads += c.leads;\n signups += c.signups;\n spamAuto += c.spamAuto;\n const site = sitesById.get(siteId);\n if (site && c.leads + c.signups + c.spamAuto > 0) {\n bySite.push({ siteName: site.name, ...c });\n }\n }\n bySite.sort(\n (a, b) =>\n b.leads + b.signups + b.spamAuto - (a.leads + a.signups + a.spamAuto) ||\n a.siteName.localeCompare(b.siteName),\n );\n return { leads, signups, spamAuto, bySite };\n}\n\n/** Run a single collector under a try/catch: a thrown collector logs and yields []\n * so one broken signal never blanks the whole \"Needs attention\" section. */\nfunction runCollector(label: string, fn: () => AttentionItem[]): AttentionItem[] {\n try {\n return fn();\n } catch (e) {\n console.warn(`⚠ attention collector \"${label}\" failed: ${(e as Error).message}`);\n return [];\n }\n}\n\n/**\n * Fetch the free signals once (listAllReports + listWebsites) — or reuse the\n * `reports`/`websites` arrays runDigest already read, so a single run reads each\n * table once — build the sitesById map the delivery collector needs, and run each\n * pure collector isolated. Returns the union of items; diffing/badging happens in\n * runDigest.\n *\n * The Renovate + CI signals come from the SAME persisted collectors the operator\n * cockpit (`buildCockpitModel`) runs — `collectRenovateAlerts` (key\n * `renovate:<siteId>`) and `collectCiAlerts` (key `ci:<siteId>`), reading the\n * nightly-persisted `renovateFailingCis`/`defaultBranchCi` fields. This is the\n * key-space unification: because the digest writes the shared Digest State\n * snapshot with these same keys, the cockpit's NEW/WORSE diff finds them and the\n * two surfaces agree (the prior live per-PR `renovate:<repo>#<n>` sweep never\n * matched the cockpit's keys, so its cards badged NEW forever).\n */\nexport async function collectAttention(deps: CollectAttentionDeps): Promise<AttentionItem[]> {\n const reports = deps.reports ?? (await listAllReports(deps.base));\n const websites = deps.websites ?? (await listWebsites(deps.base));\n const now = deps.now ?? new Date();\n const notifyBounces = deps.notifyBounces ?? (await fetchNotifyBounceCounts(now));\n const sitesById = new Map<string, WebsiteRow>(websites.map((w) => [w.id, w]));\n return [\n ...runCollector(\"vuln\", () => collectVulnAlerts(websites, deps.baseUrl)),\n ...runCollector(\"delivery\", () => collectDeliveryFailures(reports, sitesById, deps.baseUrl)),\n ...runCollector(\"preflight\", () => collectPreflightBlocked(reports, sitesById, deps.baseUrl)),\n ...runCollector(\"lighthouse\", () => collectLighthouseAlerts(websites, deps.baseUrl)),\n ...runCollector(\"renovate\", () => collectRenovateAlerts(websites, deps.baseUrl, now)),\n ...runCollector(\"ci\", () => collectCiAlerts(websites, deps.baseUrl, now)),\n ...runCollector(\"analytics\", () => collectAnalyticsFailures(websites, deps.baseUrl, now)),\n ...runCollector(\"turnstile\", () =>\n collectTurnstileGuardrailAlerts(websites, deps.baseUrl, now),\n ),\n ...runCollector(\"notify-bounce\", () =>\n collectNotifyBounceAlerts(websites, notifyBounces, deps.baseUrl),\n ),\n ];\n}\n\nexport type DigestRunOptions = {\n resend?: ResendClient;\n /** Dashboard origin for the /s/<slug> links, e.g. \"https://reddoor-maintenance.netlify.app\". */\n baseUrl: string;\n /**\n * Inject a pre-opened Airtable base (tests, server handlers).\n * When omitted, `openBase(readAirtableConfig())` is called from the environment.\n */\n base?: AirtableBase;\n /** Per-site submission counts for the \"Submissions (24h)\" section. undefined =\n * fetch from libSQL; null = simulate unavailable (section omitted); a Map =\n * injected (tests). */\n submissionCounts?: ReadonlyMap<string, SiteSubmissionCounts> | null;\n};\n\nexport async function runDigest(\n options: DigestRunOptions,\n): Promise<{ output: string; code: number }> {\n // Capture clock BEFORE any await so the idempotency key can't roll past midnight mid-run.\n const today = new Date();\n try {\n const base = options.base ?? openBase(readAirtableConfig());\n // Read each table ONCE for the whole run, then thread the arrays into\n // collectAttention so it doesn't re-fetch (was: listWebsites ×2, listAllReports\n // ×2). Pending is derived in-line with listPendingApproval's exact predicate.\n const reports = await listAllReports(base);\n const websites = await listWebsites(base);\n const sites = new Map(websites.map((w) => [w.id, w]));\n\n const pending = reports.filter(isPendingApproval);\n\n const readyForYourYes: ReadyItem[] = [];\n const baseUrl = options.baseUrl.replace(/\\/$/, \"\");\n for (const r of pending) {\n const site = sites.get(r.siteId);\n if (!site) continue; // orphan report → skip rather than render a broken link\n // An empty Name slugs to \"\" → `/s/` is a dead link (getWebsiteBySlug can't\n // match it). Fall back to the fleet homepage so the operator still lands\n // somewhere usable instead of a 404.\n const slug = siteSlug(site.name);\n readyForYourYes.push({\n siteName: site.name,\n reportType: r.reportType,\n period: r.period ?? \"—\",\n dashboardUrl: slug ? `${baseUrl}/s/${slug}` : baseUrl,\n });\n }\n\n // M5: collect the free signals (isolated), diff against yesterday's snapshot.\n // Renovate + CI come from the persisted collectors (the same ones the cockpit\n // runs), so the snapshot this digest writes carries the `renovate:<siteId>` /\n // `ci:<siteId>` keys the cockpit diffs against — no live GitHub sweep here.\n const collected = await collectAttention({\n base,\n baseUrl: options.baseUrl,\n websites,\n reports,\n now: today,\n });\n const prior = await readDigestState(base);\n const { tagged, next } = diffAttention(collected, prior, digestDateKey(today));\n // The operator only HEARS about a vuln once Renovate's auto-fix is exhausted\n // (tried and failed a couple of nightly cycles) — before that the fleet is\n // still self-patching and the cockpit's amber Watch band is the only surface.\n // Filter the EMAIL list only: `next` (written below) must keep every vuln key\n // so the cockpit's diff against this same snapshot stays consistent, and so\n // the exhausted-flip diffs as WORSE instead of arriving pre-badged-away.\n const needsAttention = tagged.filter(\n (it) => it.kind !== \"vuln\" || it.autoFixExhausted === true,\n );\n\n // No-noise default: skip entirely when there's nothing to report.\n if (readyForYourYes.length === 0 && needsAttention.length === 0) {\n // Persist `next` even on a skip: a key that resolved on a quiet day clears so a\n // later recurrence diffs as NEW (spec §10), and — since the skip can now fire\n // with muted pre-exhaustion vulns collected — their keys keep snapshotting so\n // the eventual exhausted-flip diffs as WORSE. Wrapped: a write failure can't\n // fail the skip.\n try {\n await writeDigestState(base, next);\n } catch (e) {\n console.warn(`⚠ digest state write failed: ${(e as Error).message}`);\n }\n return { output: \"Digest skipped (nothing ready, nothing needs attention).\", code: 0 };\n }\n\n // Submissions telemetry rides only on a digest that is ALREADY sending — the\n // skip predicate above is deliberately unchanged (leads already fire their own\n // ingest-time notification; this is context, not an alarm), and fetching AFTER\n // the skip check keeps quiet days from touching libSQL at all.\n const counts =\n options.submissionCounts !== undefined\n ? options.submissionCounts\n : await fetchSubmissionsDigestCounts(today);\n const submissions = buildSubmissionsDigestSection(counts, sites);\n\n const html = renderDigestHtml({ readyForYourYes, needsAttention, submissions });\n const client = options.resend ?? defaultResendClient();\n const to = [process.env.OPERATOR_EMAIL?.trim() || DIGEST_OPERATOR_FALLBACK];\n const n = readyForYourYes.length;\n const reportWord = n === 1 ? \"report\" : \"reports\";\n let result: Awaited<ReturnType<typeof client.send>>;\n try {\n result = await client.send({\n from: FROM_ADDRESS,\n to,\n subject: `Your fleet — ${digestDateKey(today)}: ${n} ${reportWord} ready for your yes`,\n html,\n idempotencyKey: `digest-${digestDateKey(today)}`,\n });\n } catch (err) {\n // A same-UTC-day re-run whose content changed re-sends with the same\n // `digest-<date>` idempotency key but a DIFFERENT body. Resend rejects that\n // with a 409 (`invalid_idempotent_request`) — \"This idempotency key has been\n // used ... but the request body was modified ...\". The operator already got\n // today's digest on the first send, so re-sending a changed version same-day\n // would just be a duplicate: treat it as an \"already sent today\" no-op.\n //\n // ResendClient (send/resend.ts) wraps the API error as a plain Error and only\n // preserves the *message* string (no name/statusCode), so the message\n // substring is the only reliable discriminator — match defensively on it.\n // Any OTHER send error re-throws to the outer catch → {code:1}, so a genuine\n // Resend/network failure still fails loudly.\n if (isIdempotencyConflict(err)) {\n // Do NOT write the snapshot: the first send already persisted it; writing\n // this run's `next` would diff against the first run's snapshot and mis-badge.\n return {\n output:\n \"Digest already sent today (content changed since the first send) — skipped to avoid a duplicate.\",\n code: 0,\n };\n }\n throw err;\n }\n // Persist the next snapshot AFTER a successful send. A write failure is caught +\n // logged: the digest already went out, tomorrow re-news at worst. (The send-FAILURE\n // path never reaches here — the outer catch returns code 1 with no write, preserving\n // the NEW badge for the retry.)\n try {\n await writeDigestState(base, next);\n } catch (e) {\n console.warn(`⚠ digest state write failed: ${(e as Error).message}`);\n }\n return { output: `Digest sent to ${to.join(\", \")} (${result.messageId})`, code: 0 };\n } catch (err) {\n // Re-throw config errors (exitCode=2: missing env vars, bad config) so runOrExit\n // surfaces them with the correct process exit code rather than collapsing to 1.\n if (typeof (err as { exitCode?: unknown }).exitCode === \"number\") {\n throw err;\n }\n const message = err instanceof Error ? err.message : String(err);\n return { output: `digest failed: ${message}`, code: 1 };\n }\n}\n\n/** Pure render of the unified daily operator digest. No IO — the caller (runDigest)\n * collects the rows and decides whether to send. */\nexport function renderDigestHtml(sections: DigestSections): string {\n return `<!doctype html>\n<html>\n <head><meta charset=\"utf-8\"></head>\n <body style=\"margin:0;padding:0;background:#ffffff\">\n <table width=\"100%\" style=\"border-collapse:collapse\">\n <tr>\n <td align=\"center\" style=\"padding:24px\">\n <table width=\"600\" style=\"border-collapse:collapse\">\n <tr>\n <td>\n <h1 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px\">Your fleet today</h1>\n ${readySection(sections.readyForYourYes)}\n ${attentionSection(sections.needsAttention)}\n ${submissionsSection(sections.submissions)}\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </body>\n</html>`;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/analytics-health.ts","../src/cli/commands/report.ts"],"sourcesContent":["import { escapeHtml } from \"../util/html.js\";\n\n/**\n * The GA/Search enrichment outcome of one `report --due` draft run, aggregated\n * across the sites it drafted. `softFailedSites` is how many drafted sites had GA\n * OR Search enrichment ERROR (not \"not configured\" — that's a legitimate skip);\n * `configuredSites` is how many drafted sites had analytics configured at all\n * (the subject is set AND the site has a GA4 property or a search query). The ratio\n * is what distinguishes a fleet-wide subject outage from a single transient blip.\n */\nexport type AnalyticsRunHealth = {\n softFailedSites: number;\n configuredSites: number;\n};\n\nexport type AnalyticsAlert = { fire: boolean; reason: string };\n\n/** A lone failure never alerts — that's a per-site config issue, not the shared-\n * subject SPOF. Two-plus AND a majority of configured sites is the fleet signature. */\nconst MIN_FAILED_SITES = 2;\n\n/**\n * Decide whether a draft run's GA/Search soft-failures look FLEET-WIDE — i.e. the\n * one impersonated subject (`GA_SUBJECT`) lost access — rather than one site's\n * transient blip. PURE. Fires when at least {@link MIN_FAILED_SITES} configured\n * sites failed AND they are a majority (≥ half) of the analytics-configured sites\n * this run. With <2 configured sites it can't distinguish a SPOF from a one-off, so\n * it never fires (at fleet scale there are always many configured sites).\n */\nexport function assessAnalyticsAlert(h: AnalyticsRunHealth): AnalyticsAlert {\n const { softFailedSites, configuredSites } = h;\n const fire =\n configuredSites >= 2 &&\n softFailedSites >= MIN_FAILED_SITES &&\n softFailedSites * 2 >= configuredSites;\n const reason = fire\n ? `${softFailedSites} of ${configuredSites} analytics-configured sites had GA/Search enrichment fail this run — the shared GA_SUBJECT likely lost access (an offboarded user, revoked property access, or a botched role-account cutover). Reports were drafted with BLANK analytics.`\n : \"\";\n return { fire, reason };\n}\n\n/**\n * Compose the operator alert email for a fleet-wide analytics failure. PURE — the\n * caller (the `report --due` cron) sends it best-effort via Resend only when\n * {@link assessAnalyticsAlert} fires. `dashboardUrl` is the fleet homepage link.\n */\nexport function composeAnalyticsAlertEmail(\n h: AnalyticsRunHealth,\n dashboardUrl: string,\n): { subject: string; html: string } {\n const subject = `⚠ Fleet analytics enrichment failing — ${h.softFailedSites}/${h.configuredSites} sites`;\n const { reason } = assessAnalyticsAlert(h);\n const html = `<p><strong>${escapeHtml(reason)}</strong></p>\n<p>This usually means the Google Workspace user the service account impersonates can no longer read the GA4 / Search Console properties. Reports still send — but with blank analytics — until the subject is restored.</p>\n<p>Next step: follow the GA/Search subject runbook (<code>docs/runbooks/ga-search-role-account-cutover.md</code>) to restore or move the subject, then re-run <code>reddoor-maint report --due</code> and confirm the warning clears.</p>\n<p><a href=\"${escapeHtml(dashboardUrl)}\">Open the fleet dashboard →</a></p>`;\n return { subject, html };\n}\n","import { openBase, readAirtableConfig, type AirtableBase } from \"../../reports/airtable/client.js\";\nimport {\n listWebsites,\n siteSlug,\n updateNextDueDates,\n type WebsiteRow,\n} from \"../../reports/airtable/websites.js\";\nimport { listAllReports, type ReportRow } from \"../../reports/airtable/reports.js\";\nimport { findDueReports, nextDueDate, reportPeriodKey } from \"../../reports/due.js\";\nimport { draftReportForSite } from \"../../reports/draft.js\";\nimport { reportTier } from \"../../reports/queue.js\";\nimport { readGaConfig } from \"../../reports/ga/config.js\";\nimport {\n assessAnalyticsAlert,\n composeAnalyticsAlertEmail,\n type AnalyticsRunHealth,\n} from \"../../alerts/analytics-health.js\";\nimport type { ReportType } from \"../../reports/types.js\";\n\nexport type ReportCommandOptions = {\n due?: boolean;\n preview?: boolean;\n sendReady?: boolean;\n digest?: boolean;\n type?: string;\n cwd?: string;\n};\n\n/**\n * Summary line for a drafted report, reflecting the single-queue outcome. `queued === false`\n * means a higher-or-equal-tier report was already pending for the site, so this draft was\n * created but deliberately left OUT of the approve queue. A non-empty `supersededIds` means it\n * un-queued that many lower-tier drafts. `null` is the previewOnly path (no Airtable queue).\n */\nfunction draftLine(\n reportId: string | undefined,\n queued: boolean | null,\n supersededIds: string[],\n verb = \"drafted\",\n): string {\n const id = reportId ?? \"(unknown)\";\n if (queued === false) {\n return `• ${verb} but NOT queued: ${id} — a higher-or-equal-tier report is already pending approval`;\n }\n const sup =\n supersededIds.length > 0\n ? ` (superseded ${supersededIds.length} lower-tier draft${supersededIds.length > 1 ? \"s\" : \"\"})`\n : \"\";\n return `✓ ${verb}: ${id}${sup}`;\n}\n\n/**\n * Parse the single-site `--type` flag. Only Maintenance and Testing are draftable\n * this way — Launch has the `launch <site>` command and Announcement has\n * `announce <site>`, each with its own purpose-built flow. Case-insensitive;\n * defaults to Maintenance (the historical single-site behaviour). Throws an\n * exitCode-2 usage error on anything else. PURE.\n */\nexport function parseSingleSiteReportType(raw: string | undefined): ReportType {\n if (raw === undefined || raw.trim() === \"\") return \"Maintenance\";\n const norm = raw.trim().toLowerCase();\n if (norm === \"maintenance\") return \"Maintenance\";\n if (norm === \"testing\") return \"Testing\";\n const hint =\n norm === \"launch\"\n ? \" — use the `launch <site>` command\"\n : norm === \"announcement\"\n ? \" — use the `announce <site>` command\"\n : \"\";\n throw Object.assign(\n new Error(`--type must be Maintenance or Testing (got ${JSON.stringify(raw)})${hint}`),\n { exitCode: 2 },\n );\n}\n\n/** Dashboard origin for digest /s/<slug> links. DASHBOARD_BASE_URL overrides the\n * production default; the trailing slash (if any) is trimmed by runDigest. */\nfunction dashboardBaseUrl(): string {\n return process.env.DASHBOARD_BASE_URL?.trim() || \"https://reddoor-maintenance.netlify.app\";\n}\n\nexport async function runReportCommand(\n slug: string | undefined,\n opts: ReportCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (opts.digest) {\n const { runDigest } = await import(\"../../reports/digest.js\");\n return runDigest({ baseUrl: dashboardBaseUrl() });\n }\n\n if (opts.sendReady) {\n const { sendApprovedReports } = await import(\"../../reports/send/orchestrate.js\");\n return sendApprovedReports();\n }\n\n if (opts.due) {\n return runDueDraft();\n }\n\n if (slug) {\n // Validate the type BEFORE any Airtable access so a bad --type fails fast (and\n // without needing credentials).\n const reportType = parseSingleSiteReportType(opts.type);\n return runSingleSiteDraft(slug, { previewOnly: Boolean(opts.preview), reportType });\n }\n\n throw Object.assign(\n new Error(\n \"Usage: reddoor-maint report [<slug>] [--type <Maintenance|Testing>] [--due] [--preview] [--send-ready] [--digest]\",\n ),\n {\n exitCode: 2,\n },\n );\n}\n\nasync function runDueDraft(): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n const result = await draftDueReports(base, new Date());\n await alertOnFleetAnalyticsFailure(result.health);\n return { output: result.output, code: result.code };\n}\n\n/** Best-effort: when a draft run's GA/Search soft-failures look FLEET-WIDE (the shared\n * GA_SUBJECT lost access — see assessAnalyticsAlert), email the operator one alert.\n * NEVER throws — a Resend or config hiccup must not fail the nightly draft cron; the\n * per-site `⚠ GA skipped` warnings + the run-output line still carry the signal. The\n * daily idempotency key dedupes multiple runs in one day (Resend dedupes 24h). */\nasync function alertOnFleetAnalyticsFailure(health: AnalyticsRunHealth): Promise<void> {\n if (!assessAnalyticsAlert(health).fire) return;\n try {\n const to = process.env.OPERATOR_EMAIL?.trim() || \"info@reddoorla.com\";\n const { subject, html } = composeAnalyticsAlertEmail(health, dashboardBaseUrl());\n const { defaultResendClient } = await import(\"../../reports/send/resend.js\");\n await defaultResendClient().send({\n from: \"Reddoor Reports <reports@reddoorla.com>\",\n to: [to],\n subject,\n html,\n idempotencyKey: `analytics-alert-${new Date().toISOString().slice(0, 10)}`,\n });\n console.warn(`⚠ ${subject} — operator alerted (${to})`);\n } catch (e) {\n console.warn(`⚠ analytics-failure alert send failed: ${(e as Error).message}`);\n }\n}\n\n/**\n * Write each site's code-computed next-maintenance / next-testing date back to Airtable\n * (date-only, or null when there's no schedule), so the \"next\" dates shown there derive\n * from the SAME `nextDueDate` the scheduler uses — replacing the old Airtable formula +\n * automation. Best-effort and per-site isolated: a missing `Next … at` column or one bad\n * row warns and is skipped, never aborting the nightly draft run.\n */\nasync function writeNextDueDates(\n base: AirtableBase,\n websites: WebsiteRow[],\n reports: ReportRow[],\n today: Date,\n): Promise<void> {\n const ymd = (d: Date | null): string | null => (d ? d.toISOString().slice(0, 10) : null);\n for (const site of websites) {\n try {\n await updateNextDueDates(base, site.id, {\n maintenanceAt: ymd(nextDueDate(site, reports, \"Maintenance\", today)),\n testingAt: ymd(nextDueDate(site, reports, \"Testing\", today)),\n });\n } catch (e) {\n console.warn(`⚠ next-due write skipped for ${site.name}: ${(e as Error).message}`);\n }\n }\n}\n\nexport async function draftDueReports(\n base: AirtableBase,\n today: Date,\n): Promise<{ output: string; code: number; health: AnalyticsRunHealth }> {\n const websites = await listWebsites(base);\n // ONE unfiltered fetch for the whole fleet. Per-site queries can't be pushed to\n // Airtable anyway (linked-record fields aren't formula-filterable by record id),\n // and findDueReports + the period guard below match on siteId in memory.\n const reports = await listAllReports(base);\n\n // Refresh every site's code-owned next-due dates first, so they stay current even on\n // a run where nothing is due (the early return below).\n await writeNextDueDates(base, websites, reports, today);\n\n const due = findDueReports(websites, reports, today);\n\n // GA/Search enrichment is configured globally (the impersonation subject) AND\n // per-site (a GA4 property or a search query). `gaConfigured` is the global half;\n // the per-site half is checked as each draft runs, to build the fleet-wide\n // analytics-failure alert's denominator (see alertOnFleetAnalyticsFailure).\n const gaConfigured = readGaConfig() !== null;\n // Truthy (not `!== null`) to mirror fetchGaUsers/fetchSearch's own gate exactly\n // (`!siteRow.ga4PropertyId`), so an empty-string cell counts as not-configured in\n // BOTH places and can't inflate the alert denominator.\n const isAnalyticsConfigured = (s: WebsiteRow): boolean =>\n gaConfigured && Boolean(s.ga4PropertyId || s.searchQuery);\n\n if (due.length === 0) {\n return {\n output: \"No reports due.\",\n code: 0,\n health: { softFailedSites: 0, configuredSites: 0 },\n };\n }\n\n const lines: string[] = [];\n let softFailedSites = 0;\n let searchDefaultMisses = 0;\n let searchPropertiesMissing = 0;\n let gaConfiguredSites = 0;\n let skipped = 0;\n for (const item of due) {\n // Idempotency: a re-run must not re-draft a (site, type) already drafted this\n // recurrence. The dueDate's YYYY-MM is the stable per-cycle key. Match against the\n // reports we already fetched — no extra query on the hot path.\n const period = reportPeriodKey(item.dueDate);\n const existing = reports.find(\n (r) => r.siteId === item.site.id && r.reportType === item.reportType && r.period === period,\n );\n\n // A row already exists for THIS period. Two cases:\n // - Draft ready → truly done, skip (the idempotent re-run path).\n // - NOT ready → a crash between createDraft and setDraftReady wedged it: the\n // row exists (so we never re-draft) yet it's never sendable (listSendable\n // needs Draft ready). COMPLETE it in place instead of skipping forever —\n // re-render → re-upload the HTML → flip Draft ready on the EXISTING row.\n if (existing) {\n if (existing.draftReady) {\n skipped++;\n lines.push(`• skipped (already drafted ${period}): ${item.site.name} ${item.reportType}`);\n continue;\n }\n // A not-ready row is normally a crash between createDraft and setDraftReady — re-complete\n // it in place. BUT queueDraft also clears Draft ready on rows it supersedes/blocks, and\n // those must NOT be re-completed: doing so would re-render and APPEND a duplicate HTML\n // attachment every nightly run, only to be re-blocked. Distinguish the two: if a\n // higher-or-equal-tier report is still pending for this site, this row was intentionally\n // un-queued (not crashed) — skip it until the blocker is sent/approved or the month rolls.\n const blockedByPending = reports.some(\n (r) =>\n r.siteId === item.site.id &&\n r.id !== existing.id &&\n r.sentAt === null &&\n r.draftReady &&\n reportTier(r.reportType) >= reportTier(item.reportType),\n );\n if (blockedByPending) {\n skipped++;\n lines.push(\n `• skipped (superseded — a higher-or-equal-tier report is pending): ${item.site.name} ${item.reportType}`,\n );\n continue;\n }\n try {\n const result = await draftReportForSite(base, item.site, item.reportType, {\n period,\n completeRowId: existing.id,\n existingRow: existing,\n });\n existing.draftReady = result.queued === true;\n lines.push(\n draftLine(\n result.reportRow?.reportId ?? existing.reportId,\n result.queued,\n result.supersededIds,\n \"completed half-made draft\",\n ),\n );\n if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;\n if (result.softFailures.length > 0) softFailedSites++;\n if (result.searchDefaultMissed) searchDefaultMisses++;\n if (result.searchPropertyMissing) searchPropertiesMissing++;\n } catch (e) {\n lines.push(`✗ failed: ${item.site.name} ${item.reportType} — ${(e as Error).message}`);\n }\n continue;\n }\n\n // Pile-up guard: don't accrue a fresh new-period draft every recurrence for a\n // site nobody ever approves. The period key follows the DUE month, so each\n // recurrence wants a new (later-period) draft — but if a PRIOR draft is still\n // pending approval, a new one just stacks. Skip creating the new one while an\n // earlier-period draft for this (site, type) sits ready-but-unsent.\n //\n // `r.draftReady` is load-bearing: a draft a higher tier SUPERSEDED has\n // draftReady=false and never gets a Sent at, so without this clause it would\n // match (sentAt null + earlier period) and block EVERY future draft for the\n // site forever. Pending-approval means draftReady=true AND sentAt=null.\n const pendingEarlier = reports.find(\n (r) =>\n r.siteId === item.site.id &&\n r.reportType === item.reportType &&\n r.draftReady &&\n r.sentAt === null &&\n r.period !== null &&\n r.period < period,\n );\n if (pendingEarlier) {\n skipped++;\n lines.push(\n `• skipped: ${item.site.name} ${item.reportType} already has an unsent ${pendingEarlier.period} draft pending approval`,\n );\n continue;\n }\n\n try {\n // Pass the SAME key the guard searches by, so the stamped Period always\n // matches a future run's reportPeriodKey(dueDate) — even if this run lags\n // into a later month than the dueDate.\n const result = await draftReportForSite(base, item.site, item.reportType, { period });\n lines.push(draftLine(result.reportRow?.reportId, result.queued, result.supersededIds));\n // Keep the in-memory snapshot current so the guard's `.some()` check on the\n // NEXT iteration of this same run catches a row we JUST created — rather than\n // relying on findDueReports never emitting two items for the same (site, type).\n if (result.reportRow) reports.push(result.reportRow);\n // Count sites (not individual GA/Search failures) so a fleet-wide enrichment\n // outage is one obvious line at the bottom, not 200 buried console.warns.\n if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;\n if (result.softFailures.length > 0) softFailedSites++;\n if (result.searchDefaultMissed) searchDefaultMisses++;\n if (result.searchPropertyMissing) searchPropertiesMissing++;\n } catch (e) {\n lines.push(`✗ failed: ${item.site.name} ${item.reportType} — ${(e as Error).message}`);\n }\n }\n if (skipped > 0) {\n lines.push(`• ${skipped} already drafted or pending this period`);\n }\n if (softFailedSites > 0) {\n lines.push(\n `⚠ ${softFailedSites} site${softFailedSites === 1 ? \"\" : \"s\"} had GA/Search enrichment fail — drafted with blank analytics; check the logs above`,\n );\n }\n if (searchDefaultMisses > 0) {\n lines.push(\n `⚑ ${searchDefaultMisses} site${searchDefaultMisses === 1 ? \"\" : \"s\"} returned no Search Console data for their name — set an explicit \"Search query\" in Airtable to track brand presence.`,\n );\n }\n if (searchPropertiesMissing > 0) {\n lines.push(\n `⚑ ${searchPropertiesMissing} site${searchPropertiesMissing === 1 ? \"\" : \"s\"} matched NO Search Console property — verify the domain property exists and the service account has access (a \"Search query\" change cannot fix this).`,\n );\n }\n return {\n output: lines.join(\"\\n\"),\n code: lines.some((l) => l.startsWith(\"✗\")) ? 1 : 0,\n health: { softFailedSites, configuredSites: gaConfiguredSites },\n };\n}\n\nasync function runSingleSiteDraft(\n slug: string,\n opts: { previewOnly: boolean; reportType: ReportType },\n): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n const site = websites.find((w) => siteSlug(w.name) === slug);\n if (!site) {\n throw Object.assign(new Error(`No Websites row matched slug \"${slug}\"`), { exitCode: 2 });\n }\n const result = await draftReportForSite(opts.previewOnly ? null : base, site, opts.reportType, {\n previewOnly: opts.previewOnly,\n });\n if (opts.previewOnly) {\n return { output: `Preview written to ${result.htmlPath}`, code: 0 };\n }\n return {\n output: draftLine(\n result.reportRow?.reportId,\n result.queued,\n result.supersededIds,\n \"Draft created\",\n ),\n code: 0,\n };\n}\n"],"mappings":"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","names":[]}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/package/dist/{write-audits-to-airtable-7D3LV4KP.js.map → write-audits-to-airtable-3SOXLKJK.js.map}
RENAMED
|
File without changes
|