@reddoorla/maintenance 0.69.0 → 0.72.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/README.md +15 -14
- package/dist/airtable-U2GTDTK5.js +9 -0
- package/dist/{announce-72R3LRNT.js → announce-HJCGEVJA.js} +6 -6
- package/dist/blux-FKZYMKLF.js +2205 -0
- package/dist/blux-FKZYMKLF.js.map +1 -0
- package/dist/{bump-deps-QNM3H4GI.js → bump-deps-GZEJAUKI.js} +3 -2
- package/dist/bump-deps-GZEJAUKI.js.map +1 -0
- package/dist/chunk-7MJBTV2R.js +145 -0
- package/dist/chunk-7MJBTV2R.js.map +1 -0
- package/dist/{chunk-2DCA7FHZ.js → chunk-7P7YETHG.js} +34 -12
- package/dist/chunk-7P7YETHG.js.map +1 -0
- package/dist/{chunk-42KKVLYN.js → chunk-APTAITXZ.js} +10 -2
- package/dist/chunk-APTAITXZ.js.map +1 -0
- package/dist/{chunk-6G7BC5SN.js → chunk-BHE5CUWI.js} +392 -7
- package/dist/chunk-BHE5CUWI.js.map +1 -0
- package/dist/{chunk-XWPZ46IN.js → chunk-BJCA7O4A.js} +2 -2
- package/dist/{chunk-C3PDS623.js → chunk-BWKK2ZXS.js} +67 -8
- package/dist/chunk-BWKK2ZXS.js.map +1 -0
- package/dist/{chunk-45AUGDFR.js → chunk-D4FVAIEJ.js} +6 -20
- package/dist/chunk-D4FVAIEJ.js.map +1 -0
- package/dist/{chunk-YQMYQAJX.js → chunk-GI4M2AMO.js} +2 -2
- package/dist/{chunk-OTBA7DON.js → chunk-GYNRZCM6.js} +7 -2
- package/dist/chunk-GYNRZCM6.js.map +1 -0
- package/dist/{chunk-6MT5W6UF.js → chunk-HR5SONJC.js} +6 -3
- package/dist/chunk-HR5SONJC.js.map +1 -0
- package/dist/{chunk-6JFOAPW4.js → chunk-JCITWZQR.js} +51 -19
- package/dist/chunk-JCITWZQR.js.map +1 -0
- package/dist/{chunk-GKSXZIMX.js → chunk-JGLAWXUC.js} +77 -3
- package/dist/chunk-JGLAWXUC.js.map +1 -0
- package/dist/{chunk-E2GKKUEX.js → chunk-KLB2OLPW.js} +26 -6
- package/dist/chunk-KLB2OLPW.js.map +1 -0
- package/dist/{chunk-RINXPU3H.js → chunk-KLVMJYZZ.js} +2 -2
- package/dist/chunk-KRDLHAKK.js +17 -0
- package/dist/chunk-KRDLHAKK.js.map +1 -0
- package/dist/{chunk-AESJBEM7.js → chunk-KRHPS3R4.js} +3 -3
- package/dist/{chunk-LYCKN7ZR.js → chunk-LJPCMXR7.js} +8 -5
- package/dist/chunk-LJPCMXR7.js.map +1 -0
- package/dist/{chunk-O6G4HHMF.js → chunk-NRISG4XD.js} +308 -104
- package/dist/chunk-NRISG4XD.js.map +1 -0
- package/dist/chunk-PKVZMFEB.js +315 -0
- package/dist/chunk-PKVZMFEB.js.map +1 -0
- package/dist/chunk-SL24DCGC.js +41 -0
- package/dist/chunk-SL24DCGC.js.map +1 -0
- package/dist/{chunk-A5KYRGQA.js → chunk-SQJ53Z7R.js} +4 -4
- package/dist/cli/bin.js +63 -19
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +1 -1
- package/dist/cli/commands/audit.js +11 -9
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/configs/playwright-a11y.d.ts +1 -1
- package/dist/configs/playwright-a11y.js +1 -3
- package/dist/{convert-to-pnpm-XH7NYRVK.js → convert-to-pnpm-SVFDLXZS.js} +3 -2
- package/dist/convert-to-pnpm-SVFDLXZS.js.map +1 -0
- package/dist/{digest-Q2TAQHXF.js → digest-VX7Q4MNF.js} +8 -8
- package/dist/{ensure-site-2PF522VL.js → ensure-site-O7G5ZJS7.js} +2 -2
- package/dist/forms/index.d.ts +5 -2
- package/dist/forms/index.js +22 -9
- package/dist/forms/index.js.map +1 -1
- package/dist/{github-signals-CLQC2IPY.js → github-signals-QVW2LB7N.js} +5 -5
- package/dist/health-endpoint-IYR5VBUT.js +54 -0
- package/dist/health-endpoint-IYR5VBUT.js.map +1 -0
- package/dist/index.d.ts +136 -20
- package/dist/index.js +207 -64
- package/dist/index.js.map +1 -1
- package/dist/{init-UA6D7GKW.js → init-CU6PVZRG.js} +9 -5
- package/dist/init-CU6PVZRG.js.map +1 -0
- package/dist/{launch-AENAS2O2.js → launch-JWXCTJRG.js} +9 -9
- package/dist/{lighthouse-airtable-UBSXKHAQ.js → lighthouse-airtable-J5TLASJC.js} +3 -3
- package/dist/{onboard-X6KJ5Z2V.js → onboard-6UQE5QIO.js} +2 -2
- package/dist/{orchestrate-GADNFGDH.js → orchestrate-ZDH24Z6Y.js} +5 -5
- package/dist/{preflight-D5RQGX4V.js → preflight-BNTMWTPG.js} +7 -7
- package/dist/probe-DJAMLVTT.js +46 -0
- package/dist/probe-DJAMLVTT.js.map +1 -0
- package/dist/recipes/sync-configs.d.ts +1 -1
- package/dist/{renovate-dispatch-LG4RJGDY.js → renovate-dispatch-C3ALLACX.js} +2 -2
- package/dist/{report-3FJ224H5.js → report-YLFSGNWR.js} +12 -12
- package/dist/run-migration-LJNNZWIV.js +205 -0
- package/dist/run-migration-LJNNZWIV.js.map +1 -0
- package/dist/{self-updating-JTTFOJ7Q.js → self-updating-64FNRQB5.js} +2 -2
- package/dist/{selftest-55OJIWEB.js → selftest-7DPNGL6R.js} +12 -9
- package/dist/selftest-7DPNGL6R.js.map +1 -0
- package/dist/smoke-suite-TWILWDSF.js +54 -0
- package/dist/smoke-suite-TWILWDSF.js.map +1 -0
- package/dist/{svelte-codemods-KJLNH6FQ.js → svelte-codemods-RVDTTXFD.js} +3 -2
- package/dist/svelte-codemods-RVDTTXFD.js.map +1 -0
- package/dist/{sync-configs-TIBO6DUJ.js → sync-configs-BUUD6L5Q.js} +3 -2
- package/dist/sync-configs-BUUD6L5Q.js.map +1 -0
- package/dist/{types-Dg4mHvyf.d.ts → types-0jreMCF5.d.ts} +2 -2
- package/dist/{upgrade-TPTE26FG.js → upgrade-HHKWYJRN.js} +3 -2
- package/dist/upgrade-HHKWYJRN.js.map +1 -0
- package/dist/{websites-6YTT5ZTO.js → websites-EH2ULELZ.js} +8 -2
- package/dist/{write-audits-to-airtable-IWOO4FFH.js → write-audits-to-airtable-LZDYTNX5.js} +4 -4
- package/package.json +4 -1
- package/dist/airtable-WGQQ3KAD.js +0 -9
- package/dist/bump-deps-QNM3H4GI.js.map +0 -1
- package/dist/chunk-2DCA7FHZ.js.map +0 -1
- package/dist/chunk-42KKVLYN.js.map +0 -1
- package/dist/chunk-45AUGDFR.js.map +0 -1
- package/dist/chunk-6G7BC5SN.js.map +0 -1
- package/dist/chunk-6JFOAPW4.js.map +0 -1
- package/dist/chunk-6MT5W6UF.js.map +0 -1
- package/dist/chunk-C3PDS623.js.map +0 -1
- package/dist/chunk-E2GKKUEX.js.map +0 -1
- package/dist/chunk-GKSXZIMX.js.map +0 -1
- package/dist/chunk-LYCKN7ZR.js.map +0 -1
- package/dist/chunk-O6G4HHMF.js.map +0 -1
- package/dist/chunk-OTBA7DON.js.map +0 -1
- package/dist/convert-to-pnpm-XH7NYRVK.js.map +0 -1
- package/dist/init-UA6D7GKW.js.map +0 -1
- package/dist/selftest-55OJIWEB.js.map +0 -1
- package/dist/svelte-codemods-KJLNH6FQ.js.map +0 -1
- package/dist/sync-configs-TIBO6DUJ.js.map +0 -1
- package/dist/upgrade-TPTE26FG.js.map +0 -1
- /package/dist/{airtable-WGQQ3KAD.js.map → airtable-U2GTDTK5.js.map} +0 -0
- /package/dist/{announce-72R3LRNT.js.map → announce-HJCGEVJA.js.map} +0 -0
- /package/dist/{chunk-XWPZ46IN.js.map → chunk-BJCA7O4A.js.map} +0 -0
- /package/dist/{chunk-YQMYQAJX.js.map → chunk-GI4M2AMO.js.map} +0 -0
- /package/dist/{chunk-RINXPU3H.js.map → chunk-KLVMJYZZ.js.map} +0 -0
- /package/dist/{chunk-AESJBEM7.js.map → chunk-KRHPS3R4.js.map} +0 -0
- /package/dist/{chunk-A5KYRGQA.js.map → chunk-SQJ53Z7R.js.map} +0 -0
- /package/dist/{digest-Q2TAQHXF.js.map → digest-VX7Q4MNF.js.map} +0 -0
- /package/dist/{ensure-site-2PF522VL.js.map → ensure-site-O7G5ZJS7.js.map} +0 -0
- /package/dist/{github-signals-CLQC2IPY.js.map → github-signals-QVW2LB7N.js.map} +0 -0
- /package/dist/{launch-AENAS2O2.js.map → launch-JWXCTJRG.js.map} +0 -0
- /package/dist/{lighthouse-airtable-UBSXKHAQ.js.map → lighthouse-airtable-J5TLASJC.js.map} +0 -0
- /package/dist/{onboard-X6KJ5Z2V.js.map → onboard-6UQE5QIO.js.map} +0 -0
- /package/dist/{orchestrate-GADNFGDH.js.map → orchestrate-ZDH24Z6Y.js.map} +0 -0
- /package/dist/{preflight-D5RQGX4V.js.map → preflight-BNTMWTPG.js.map} +0 -0
- /package/dist/{renovate-dispatch-LG4RJGDY.js.map → renovate-dispatch-C3ALLACX.js.map} +0 -0
- /package/dist/{report-3FJ224H5.js.map → report-YLFSGNWR.js.map} +0 -0
- /package/dist/{self-updating-JTTFOJ7Q.js.map → self-updating-64FNRQB5.js.map} +0 -0
- /package/dist/{websites-6YTT5ZTO.js.map → websites-EH2ULELZ.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-IWOO4FFH.js.map → write-audits-to-airtable-LZDYTNX5.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
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\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 /** When the `function-health` audit last ran — the freshness gate for BOTH `functionHealth` and\n * `cmsReachable`. 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. When true, a submission whose Turnstile\n * token verifies as \"fail\" is escalated to auto-spam regardless of content score\n * (an absent/unverifiable token stays neutral — fail-open). Airtable checkbox. */\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\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 acceptedWatchConditions: Array.isArray(f[\"Accepted Watch Conditions\"])\n ? (f[\"Accepted Watch Conditions\"] as string[])\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 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};\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 /** When the audit ran (freshness stamp for both 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 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 };\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 \"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,10 +1,5 @@
|
|
|
1
1
|
// src/reports/due.ts
|
|
2
|
-
var ELIGIBLE_STATUSES = /* @__PURE__ */ new Set([
|
|
3
|
-
"in development",
|
|
4
|
-
"launch period",
|
|
5
|
-
"maintenance",
|
|
6
|
-
"hosting"
|
|
7
|
-
]);
|
|
2
|
+
var ELIGIBLE_STATUSES = /* @__PURE__ */ new Set(["maintenance", "hosting"]);
|
|
8
3
|
var MONTHS = {
|
|
9
4
|
Monthly: 1,
|
|
10
5
|
Quarterly: 3,
|
|
@@ -32,10 +27,8 @@ function lastSentForType(reports, siteId, type) {
|
|
|
32
27
|
}
|
|
33
28
|
function nextDueDate(site, reports, type, today) {
|
|
34
29
|
if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) return null;
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
if (freq === "None" || freq === "") return null;
|
|
38
|
-
if (!(freq in MONTHS)) return null;
|
|
30
|
+
const freq = type === "Maintenance" ? site.maintenanceFreq : site.testingFreq;
|
|
31
|
+
if (freq === "None") return null;
|
|
39
32
|
const lastSent = lastSentForType(reports, site.id, type);
|
|
40
33
|
const fallback = type === "Maintenance" ? site.maintenanceDay : site.testingDay;
|
|
41
34
|
const baseIso = lastSent ?? fallback;
|
|
@@ -48,15 +41,8 @@ function findDueReports(websites, reports, today) {
|
|
|
48
41
|
for (const site of websites) {
|
|
49
42
|
if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) continue;
|
|
50
43
|
for (const type of ["Maintenance", "Testing"]) {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
if (freq === "None" || freq === "") continue;
|
|
54
|
-
if (!(freq in MONTHS)) {
|
|
55
|
-
console.warn(
|
|
56
|
-
`\u26A0 ${site.name}: unrecognized ${type === "Maintenance" ? "maintenance" : "testing"} frequency '${rawFreq}' \u2014 not scheduling; fix the Airtable value`
|
|
57
|
-
);
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
44
|
+
const freq = type === "Maintenance" ? site.maintenanceFreq : site.testingFreq;
|
|
45
|
+
if (freq === "None") continue;
|
|
60
46
|
const lastSent = lastSentForType(reports, site.id, type);
|
|
61
47
|
const dueDate = nextDueDate(site, reports, type, today);
|
|
62
48
|
if (dueDate !== null && todayStart.getTime() >= startOfDay(dueDate).getTime()) {
|
|
@@ -79,4 +65,4 @@ export {
|
|
|
79
65
|
findDueReports,
|
|
80
66
|
reportPeriodKey
|
|
81
67
|
};
|
|
82
|
-
//# sourceMappingURL=chunk-
|
|
68
|
+
//# sourceMappingURL=chunk-D4FVAIEJ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/due.ts"],"sourcesContent":["import type { WebsiteRow, Frequency, Status } from \"./airtable/websites.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport type { ReportType } from \"./types.js\";\n\n/** Statuses where recurring Maintenance/Testing reports are appropriate. Only\n * LIVE sites: \"maintenance\" (actively maintained) and \"hosting\". Pre-launch\n * stages (\"in development\" / \"launch period\") are excluded — a not-yet-live site\n * must not be drafted a recurring Maintenance/Testing report (that's what was\n * emailing/alarming pre-launch sites); it starts its report cadence when a Launch\n * report flips its Status to \"maintenance\". Launch reports themselves are a\n * separate manual flow (recipes/launch.ts), never scheduled here. \"deprecated\" /\n * \"probably not our problem\" are dropped too. Sites with status=null pass through\n * (partial data; better to surface than silently skip). */\nexport const ELIGIBLE_STATUSES: ReadonlySet<Status> = new Set<Status>([\"maintenance\", \"hosting\"]);\n\nexport type DueItem = {\n site: WebsiteRow;\n reportType: ReportType;\n /** Inclusive: the day the next report became due. */\n dueDate: Date;\n /** ISO date of the last `Sent at` for this (site, type), or null if there's never been one. */\n lastSent: string | null;\n};\n\nconst MONTHS: Record<Exclude<Frequency, \"None\">, number> = {\n Monthly: 1,\n Quarterly: 3,\n Yearly: 12,\n};\n\n/**\n * Add `n` calendar months in UTC, clamped to the last day of the target month.\n * Jan 31 + 1 month = Feb 28 (not Mar 3, which is what naive setMonth produces).\n * All-UTC accessors mean the result is timezone-independent.\n */\nfunction addMonths(d: Date, n: number): Date {\n const out = new Date(d);\n const day = out.getUTCDate();\n out.setUTCDate(1);\n out.setUTCMonth(out.getUTCMonth() + n);\n const lastDayOfTargetMonth = new Date(\n Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0),\n ).getUTCDate();\n out.setUTCDate(Math.min(day, lastDayOfTargetMonth));\n return out;\n}\n\n/** Truncate to UTC midnight. Avoids local-TZ skew when comparing Airtable date-only fields. */\nfunction startOfDay(d: Date): Date {\n const out = new Date(d);\n out.setUTCHours(0, 0, 0, 0);\n return out;\n}\n\nfunction lastSentForType(reports: ReportRow[], siteId: string, type: ReportType): string | null {\n const candidates = reports\n .filter((r) => r.siteId === siteId && r.reportType === type && r.sentAt !== null)\n .map((r) => r.sentAt!)\n .sort();\n return candidates[candidates.length - 1] ?? null;\n}\n\n/**\n * The next-due date for one (site, type): the date the next report of that type is\n * scheduled to draft, whether or not it's due yet. `null` when there's no schedule —\n * an ineligible status or a \"None\" frequency. Unrecognized/blank raw Airtable values\n * never reach here: mapRow's `toFrequency` trims, warns LOUDLY, and coerces them to\n * \"None\" at the read boundary.\n *\n * baseDate = the last `Sent at` for this (site, type), else the site's\n * `maintenance day`/`testing day` anchor. With no baseDate at all the next report is\n * due now (returns `today` at UTC midnight). Otherwise baseDate + frequency.\n *\n * Shared with {@link findDueReports} so the scheduler and any schedule display can't\n * drift on what \"next\" means.\n */\nexport function nextDueDate(\n site: WebsiteRow,\n reports: ReportRow[],\n type: ReportType,\n today: Date,\n): Date | null {\n if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) return null;\n const freq = type === \"Maintenance\" ? site.maintenanceFreq : site.testingFreq;\n if (freq === \"None\") return null;\n const lastSent = lastSentForType(reports, site.id, type);\n const fallback = type === \"Maintenance\" ? site.maintenanceDay : site.testingDay;\n const baseIso = lastSent ?? fallback;\n if (!baseIso) return startOfDay(today);\n return addMonths(new Date(baseIso), MONTHS[freq]);\n}\n\n/**\n * Computes which (site, type) pairs are due as of `today`.\n *\n * Algorithm per (site, type):\n * 1. If freq === \"None\", skip.\n * 2. baseDate = max(last Sent at for this type, site's `maintenance/testing day` fallback).\n * 3. If no baseDate exists at all, the site is due now.\n * 4. dueDate = baseDate + frequency months.\n * 5. Due iff startOfDay(today) >= startOfDay(dueDate).\n */\nexport function findDueReports(\n websites: WebsiteRow[],\n reports: ReportRow[],\n today: Date,\n): DueItem[] {\n const out: DueItem[] = [];\n const todayStart = startOfDay(today);\n\n for (const site of websites) {\n // Skip explicitly-non-active statuses (deprecated, \"probably not our problem\").\n // Null status is treated as active for backwards compat with rows that pre-date\n // the Status convention.\n if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) continue;\n\n for (const type of [\"Maintenance\", \"Testing\"] as const) {\n const freq = type === \"Maintenance\" ? site.maintenanceFreq : site.testingFreq;\n // Intentional silent skip — \"None\" (also the coerced default for blank cells)\n // means \"no schedule\", not a mistake. A trailing-space or typo'd raw Airtable\n // value never reaches here: mapRow's `toFrequency` trims (so \"Quarterly \"\n // schedules) and warns LOUDLY on anything still unrecognized before coercing\n // it to \"None\" — the guard lives at the read boundary, not in this loop.\n if (freq === \"None\") continue;\n\n const lastSent = lastSentForType(reports, site.id, type);\n // Same computation as the schedule write-back uses. The guards above already\n // proved a schedule exists, so dueDate is non-null; a no-anchor site returns\n // `today`, so the comparison below pushes it as due-now (the prior behavior).\n const dueDate = nextDueDate(site, reports, type, today);\n if (dueDate !== null && todayStart.getTime() >= startOfDay(dueDate).getTime()) {\n out.push({ site, reportType: type, dueDate, lastSent });\n }\n }\n }\n\n return out;\n}\n\n/**\n * The UTC `YYYY-MM` of a `dueDate` from {@link findDueReports} — the per-recurrence\n * idempotency key for drafting. Monthly recurrences land in distinct months; quarterly\n * and yearly land in distinct due-months too, so this uniquely names one draft per cycle.\n * UTC accessors keep it timezone-independent, consistent with the rest of this module.\n */\nexport function reportPeriodKey(dueDate: Date): string {\n if (Number.isNaN(dueDate.getTime())) throw new TypeError(\"reportPeriodKey: invalid Date\");\n const year = dueDate.getUTCFullYear();\n const month = String(dueDate.getUTCMonth() + 1).padStart(2, \"0\");\n return `${year}-${month}`;\n}\n"],"mappings":";AAaO,IAAM,oBAAyC,oBAAI,IAAY,CAAC,eAAe,SAAS,CAAC;AAWhG,IAAM,SAAqD;AAAA,EACzD,SAAS;AAAA,EACT,WAAW;AAAA,EACX,QAAQ;AACV;AAOA,SAAS,UAAU,GAAS,GAAiB;AAC3C,QAAM,MAAM,IAAI,KAAK,CAAC;AACtB,QAAM,MAAM,IAAI,WAAW;AAC3B,MAAI,WAAW,CAAC;AAChB,MAAI,YAAY,IAAI,YAAY,IAAI,CAAC;AACrC,QAAM,uBAAuB,IAAI;AAAA,IAC/B,KAAK,IAAI,IAAI,eAAe,GAAG,IAAI,YAAY,IAAI,GAAG,CAAC;AAAA,EACzD,EAAE,WAAW;AACb,MAAI,WAAW,KAAK,IAAI,KAAK,oBAAoB,CAAC;AAClD,SAAO;AACT;AAGA,SAAS,WAAW,GAAe;AACjC,QAAM,MAAM,IAAI,KAAK,CAAC;AACtB,MAAI,YAAY,GAAG,GAAG,GAAG,CAAC;AAC1B,SAAO;AACT;AAEA,SAAS,gBAAgB,SAAsB,QAAgB,MAAiC;AAC9F,QAAM,aAAa,QAChB,OAAO,CAAC,MAAM,EAAE,WAAW,UAAU,EAAE,eAAe,QAAQ,EAAE,WAAW,IAAI,EAC/E,IAAI,CAAC,MAAM,EAAE,MAAO,EACpB,KAAK;AACR,SAAO,WAAW,WAAW,SAAS,CAAC,KAAK;AAC9C;AAgBO,SAAS,YACd,MACA,SACA,MACA,OACa;AACb,MAAI,KAAK,WAAW,QAAQ,CAAC,kBAAkB,IAAI,KAAK,MAAM,EAAG,QAAO;AACxE,QAAM,OAAO,SAAS,gBAAgB,KAAK,kBAAkB,KAAK;AAClE,MAAI,SAAS,OAAQ,QAAO;AAC5B,QAAM,WAAW,gBAAgB,SAAS,KAAK,IAAI,IAAI;AACvD,QAAM,WAAW,SAAS,gBAAgB,KAAK,iBAAiB,KAAK;AACrE,QAAM,UAAU,YAAY;AAC5B,MAAI,CAAC,QAAS,QAAO,WAAW,KAAK;AACrC,SAAO,UAAU,IAAI,KAAK,OAAO,GAAG,OAAO,IAAI,CAAC;AAClD;AAYO,SAAS,eACd,UACA,SACA,OACW;AACX,QAAM,MAAiB,CAAC;AACxB,QAAM,aAAa,WAAW,KAAK;AAEnC,aAAW,QAAQ,UAAU;AAI3B,QAAI,KAAK,WAAW,QAAQ,CAAC,kBAAkB,IAAI,KAAK,MAAM,EAAG;AAEjE,eAAW,QAAQ,CAAC,eAAe,SAAS,GAAY;AACtD,YAAM,OAAO,SAAS,gBAAgB,KAAK,kBAAkB,KAAK;AAMlE,UAAI,SAAS,OAAQ;AAErB,YAAM,WAAW,gBAAgB,SAAS,KAAK,IAAI,IAAI;AAIvD,YAAM,UAAU,YAAY,MAAM,SAAS,MAAM,KAAK;AACtD,UAAI,YAAY,QAAQ,WAAW,QAAQ,KAAK,WAAW,OAAO,EAAE,QAAQ,GAAG;AAC7E,YAAI,KAAK,EAAE,MAAM,YAAY,MAAM,SAAS,SAAS,CAAC;AAAA,MACxD;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAQO,SAAS,gBAAgB,SAAuB;AACrD,MAAI,OAAO,MAAM,QAAQ,QAAQ,CAAC,EAAG,OAAM,IAAI,UAAU,+BAA+B;AACxF,QAAM,OAAO,QAAQ,eAAe;AACpC,QAAM,QAAQ,OAAO,QAAQ,YAAY,IAAI,CAAC,EAAE,SAAS,GAAG,GAAG;AAC/D,SAAO,GAAG,IAAI,IAAI,KAAK;AACzB;","names":[]}
|
|
@@ -30,7 +30,7 @@ async function resolveSites(input) {
|
|
|
30
30
|
}
|
|
31
31
|
if (input.fleet === "airtable") {
|
|
32
32
|
const { openBase, readAirtableConfig } = await import("./client-EPVO3FEH.js");
|
|
33
|
-
const { fromAirtableBase } = await import("./airtable-
|
|
33
|
+
const { fromAirtableBase } = await import("./airtable-U2GTDTK5.js");
|
|
34
34
|
const base = openBase(readAirtableConfig());
|
|
35
35
|
const provider = fromAirtableBase(base, input.workdir ? { workdir: input.workdir } : {});
|
|
36
36
|
return provider();
|
|
@@ -61,4 +61,4 @@ async function resolveSites(input) {
|
|
|
61
61
|
export {
|
|
62
62
|
resolveSites
|
|
63
63
|
};
|
|
64
|
-
//# sourceMappingURL=chunk-
|
|
64
|
+
//# sourceMappingURL=chunk-GI4M2AMO.js.map
|
|
@@ -33,13 +33,18 @@ async function recordFleetEventsBestEffort(events, now, open = () => openDb(read
|
|
|
33
33
|
}
|
|
34
34
|
try {
|
|
35
35
|
for (const e of events) await recordFleetEvent(db, e);
|
|
36
|
-
await pruneFleetEvents(db, new Date(now.getTime() - RETENTION_MS).toISOString());
|
|
37
36
|
} catch (e) {
|
|
38
37
|
console.error(`[fleet-events] write failed: ${String(e)}`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
await pruneFleetEvents(db, new Date(now.getTime() - RETENTION_MS).toISOString());
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.error(`[fleet-events] prune failed: ${String(e)}`);
|
|
39
44
|
}
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
export {
|
|
43
48
|
recordFleetEventsBestEffort
|
|
44
49
|
};
|
|
45
|
-
//# sourceMappingURL=chunk-
|
|
50
|
+
//# sourceMappingURL=chunk-GYNRZCM6.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/db/fleet-events.ts","../src/audits/fleet-events-writer.ts"],"sourcesContent":["import type { Selectable } from \"kysely\";\nimport type { Db } from \"./client.js\";\nimport type { FleetEventsTable } from \"./schema.js\";\n\nexport type FleetEventType =\n | \"pr_automerged\"\n | \"vuln_cleared\"\n | \"ci_recovered\"\n | \"site_launched\"\n | \"fleet_swept\"\n | \"cert_renewed\"\n | \"report_sent_with_override\";\n\n/** One recorded fleet activity event. `id` is deterministic (see the detectors)\n * so re-runs INSERT OR IGNORE without duplicating. `siteId`/`siteName` are null\n * for fleet-wide rollups; `data` is optional structured detail (e.g. the PR url). */\nexport type FleetEvent = {\n id: string;\n ts: string;\n type: FleetEventType;\n siteId: string | null;\n siteName: string | null;\n summary: string;\n data: unknown | null;\n};\n\n/** Tolerant JSON parse for the stored `data` column — a malformed value (should\n * never happen, we wrote it) degrades to null rather than throwing the read. */\nfunction parseData(raw: string | null): unknown | null {\n if (raw === null) return null;\n try {\n return JSON.parse(raw);\n } catch {\n return null;\n }\n}\n\nfunction rowToEvent(r: Selectable<FleetEventsTable>): FleetEvent {\n return {\n id: r.id,\n ts: r.ts,\n type: r.type as FleetEventType,\n siteId: r.site_id,\n siteName: r.site_name,\n summary: r.summary,\n data: parseData(r.data),\n };\n}\n\n/** Idempotent append: INSERT OR IGNORE on the deterministic primary key, so an\n * overlapping/re-run sweep never duplicates an event. The first write wins. */\nexport async function recordFleetEvent(db: Db, e: FleetEvent): Promise<void> {\n await db\n .insertInto(\"fleet_events\")\n .values({\n id: e.id,\n ts: e.ts,\n type: e.type,\n site_id: e.siteId,\n site_name: e.siteName,\n summary: e.summary,\n data: e.data == null ? null : JSON.stringify(e.data),\n created_at: new Date().toISOString(),\n })\n .onConflict((oc) => oc.column(\"id\").doNothing())\n .execute();\n}\n\n/** Recent events at/after `sinceIso`, newest-first, capped at `limit`. */\nexport async function listFleetEvents(\n db: Db,\n opts: { sinceIso: string; limit: number },\n): Promise<FleetEvent[]> {\n const rows = await db\n .selectFrom(\"fleet_events\")\n .selectAll()\n .where(\"ts\", \">=\", opts.sinceIso)\n .orderBy(\"ts\", \"desc\")\n .limit(opts.limit)\n .execute();\n return rows.map(rowToEvent);\n}\n\n/** Retention prune: delete events strictly older than `beforeIso`. */\nexport async function pruneFleetEvents(db: Db, beforeIso: string): Promise<void> {\n await db.deleteFrom(\"fleet_events\").where(\"ts\", \"<\", beforeIso).execute();\n}\n","import { openDb, readDbConfig, type Db } from \"../db/client.js\";\nimport { recordFleetEvent, pruneFleetEvents, type FleetEvent } from \"../db/fleet-events.js\";\n\nconst RETENTION_MS = 30 * 24 * 60 * 60 * 1000;\n\n/** Persist a batch of fleet events + prune the 30-day window — best-effort.\n * Opens its OWN libSQL connection (the producers carry only Airtable creds today).\n * A missing TURSO_* (creds not yet added to the workflow) or any write error is\n * swallowed with a console.error: recording fleet activity must NEVER fail the\n * sweep that produced it. The feed ships dark until the creds are present.\n * `open` is injectable for tests. */\nexport async function recordFleetEventsBestEffort(\n events: FleetEvent[],\n now: Date,\n open: () => Promise<Db> = () => openDb(readDbConfig()),\n): Promise<void> {\n if (events.length === 0) return;\n let db: Db;\n try {\n db = await open();\n } catch (e) {\n console.error(`[fleet-events] skipped ${events.length} event(s): no libSQL (${String(e)})`);\n return;\n }\n try {\n for (const e of events) await recordFleetEvent(db, e);\n } catch (e) {\n console.error(`[fleet-events] write failed: ${String(e)}`);\n return; // no point pruning over the same broken connection\n }\n // Prune failures get their own greppable label — a recurring prune failure\n // (table growing unbounded) must be distinguishable from a write failure.\n try {\n await pruneFleetEvents(db, new Date(now.getTime() - RETENTION_MS).toISOString());\n } catch (e) {\n console.error(`[fleet-events] prune failed: ${String(e)}`);\n }\n}\n"],"mappings":";;;;;;AAmDA,eAAsB,iBAAiB,IAAQ,GAA8B;AAC3E,QAAM,GACH,WAAW,cAAc,EACzB,OAAO;AAAA,IACN,IAAI,EAAE;AAAA,IACN,IAAI,EAAE;AAAA,IACN,MAAM,EAAE;AAAA,IACR,SAAS,EAAE;AAAA,IACX,WAAW,EAAE;AAAA,IACb,SAAS,EAAE;AAAA,IACX,MAAM,EAAE,QAAQ,OAAO,OAAO,KAAK,UAAU,EAAE,IAAI;AAAA,IACnD,aAAY,oBAAI,KAAK,GAAE,YAAY;AAAA,EACrC,CAAC,EACA,WAAW,CAAC,OAAO,GAAG,OAAO,IAAI,EAAE,UAAU,CAAC,EAC9C,QAAQ;AACb;AAkBA,eAAsB,iBAAiB,IAAQ,WAAkC;AAC/E,QAAM,GAAG,WAAW,cAAc,EAAE,MAAM,MAAM,KAAK,SAAS,EAAE,QAAQ;AAC1E;;;ACnFA,IAAM,eAAe,KAAK,KAAK,KAAK,KAAK;AAQzC,eAAsB,4BACpB,QACA,KACA,OAA0B,MAAM,OAAO,aAAa,CAAC,GACtC;AACf,MAAI,OAAO,WAAW,EAAG;AACzB,MAAI;AACJ,MAAI;AACF,SAAK,MAAM,KAAK;AAAA,EAClB,SAAS,GAAG;AACV,YAAQ,MAAM,0BAA0B,OAAO,MAAM,yBAAyB,OAAO,CAAC,CAAC,GAAG;AAC1F;AAAA,EACF;AACA,MAAI;AACF,eAAW,KAAK,OAAQ,OAAM,iBAAiB,IAAI,CAAC;AAAA,EACtD,SAAS,GAAG;AACV,YAAQ,MAAM,gCAAgC,OAAO,CAAC,CAAC,EAAE;AACzD;AAAA,EACF;AAGA,MAAI;AACF,UAAM,iBAAiB,IAAI,IAAI,KAAK,IAAI,QAAQ,IAAI,YAAY,EAAE,YAAY,CAAC;AAAA,EACjF,SAAS,GAAG;AACV,YAAQ,MAAM,gCAAgC,OAAO,CAAC,CAAC,EAAE;AAAA,EAC3D;AACF;","names":[]}
|
|
@@ -3,9 +3,10 @@ import {
|
|
|
3
3
|
} from "./chunk-U5SZ4FOP.js";
|
|
4
4
|
import {
|
|
5
5
|
ACTIVE_STATUSES,
|
|
6
|
+
isPreLaunch,
|
|
6
7
|
listWebsites,
|
|
7
8
|
siteSlug
|
|
8
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-BWKK2ZXS.js";
|
|
9
10
|
|
|
10
11
|
// src/inventory/airtable.ts
|
|
11
12
|
function fromAirtableBase(base, opts = {}) {
|
|
@@ -17,7 +18,9 @@ function fromAirtableBase(base, opts = {}) {
|
|
|
17
18
|
);
|
|
18
19
|
}
|
|
19
20
|
const websites = await listWebsites(base);
|
|
20
|
-
return websites.filter(
|
|
21
|
+
return websites.filter(
|
|
22
|
+
(w) => w.status !== null && ACTIVE_STATUSES.has(w.status) && !isPreLaunch(w.status) && w.url.length > 0
|
|
23
|
+
).flatMap((w) => {
|
|
21
24
|
const slug = siteSlug(w.name);
|
|
22
25
|
if (slug.length === 0) {
|
|
23
26
|
console.warn(
|
|
@@ -47,4 +50,4 @@ function fromAirtableBase(base, opts = {}) {
|
|
|
47
50
|
export {
|
|
48
51
|
fromAirtableBase
|
|
49
52
|
};
|
|
50
|
-
//# sourceMappingURL=chunk-
|
|
53
|
+
//# sourceMappingURL=chunk-HR5SONJC.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/inventory/airtable.ts"],"sourcesContent":["import type { Site, InventoryProvider } from \"../types.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport {\n listWebsites,\n siteSlug,\n ACTIVE_STATUSES,\n isPreLaunch,\n} from \"../reports/airtable/websites.js\";\nimport { isHttpUrl } from \"../util/url.js\";\n\nexport type AirtableInventoryOptions = {\n /**\n * Local workdir to compute each site's path as `{workdir}/{slug}`.\n * Defaults to REDDOOR_FLEET_WORKDIR env var if not provided.\n * Airtable doesn't store local checkout paths, so this is required.\n */\n workdir?: string;\n};\n\n/**\n * Read sites from the Airtable Websites table as an InventoryProvider.\n * Each row becomes one Site; `path` is computed as `{workdir}/{slug}`.\n * Only LIVE `maintenance` sites that have a `url` are included — pre-launch\n * stages (\"launch period\" / \"in development\", via isPreLaunch) are excluded so a\n * not-yet-live site is never audited as production (its deploy/domain/uptime/CMS\n * audits would fail against nothing and red its Airtable row) nor swept by other\n * `--fleet airtable` ops; it re-enters the fleet when a Launch report flips its\n * Status to \"maintenance\". The production URL is exposed as `Site.deployedUrl` so\n * the lighthouse audit can run against it with no checkout. `repoUrl` is\n * intentionally NOT set from `url` — a clone source must come from `gitRepo`\n * (`owner/repo`), never the production URL.\n */\nexport function fromAirtableBase(\n base: AirtableBase,\n opts: AirtableInventoryOptions = {},\n): InventoryProvider {\n return async (): Promise<Site[]> => {\n const workdir = opts.workdir ?? process.env.REDDOOR_FLEET_WORKDIR;\n if (!workdir) {\n throw new Error(\n \"fromAirtableBase requires `workdir` option or REDDOOR_FLEET_WORKDIR env (sites need a local path)\",\n );\n }\n const websites = await listWebsites(base);\n return websites\n .filter(\n (w) =>\n w.status !== null &&\n ACTIVE_STATUSES.has(w.status) &&\n !isPreLaunch(w.status) &&\n w.url.length > 0,\n )\n .flatMap((w) => {\n const slug = siteSlug(w.name);\n // An empty slug (a Name with no slug-able characters) can't form a stable\n // path and — fatally — can't be matched back to its Websites row on\n // write-back: every empty-slug site would collapse under the \"\" key and\n // mis-write or fail. Skip it loudly rather than silently mis-map it.\n if (slug.length === 0) {\n console.warn(\n `[inventory] skipping \"${w.name}\" (row ${w.id}): Name has no slug-able characters (empty slug)`,\n );\n return [];\n }\n const site: Site = {\n path: `${workdir}/${slug}`,\n name: slug,\n meta: { airtableRowId: w.id, displayName: w.name },\n };\n // Scheme-allowlist the Airtable `url` before exposing it as the\n // deployed-audit target (it's handed straight to Chrome/lhci). A\n // `file://`/`gopher://`/internal-host value would be a local-file read\n // or SSRF — skip the deployed audit for that site rather than trust it.\n if (isHttpUrl(w.url)) {\n site.deployedUrl = w.url;\n } else {\n console.warn(\n `[inventory] skipping deployed audit for \"${w.name}\": url is not http(s): ${JSON.stringify(w.url)}`,\n );\n }\n if (w.gitRepo) site.gitRepo = w.gitRepo;\n // Expose the Netlify site id (operator-set identity column) so the\n // netlify-deploy audit can query the API with no checkout. Absent → that\n // audit skips for this site. Not derived from the URL.\n if (w.netlifyId) site.netlifyId = w.netlifyId;\n return [site];\n });\n };\n}\n"],"mappings":";;;;;;;;;;;AAgCO,SAAS,iBACd,MACA,OAAiC,CAAC,GACf;AACnB,SAAO,YAA6B;AAClC,UAAM,UAAU,KAAK,WAAW,QAAQ,IAAI;AAC5C,QAAI,CAAC,SAAS;AACZ,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AACA,UAAM,WAAW,MAAM,aAAa,IAAI;AACxC,WAAO,SACJ;AAAA,MACC,CAAC,MACC,EAAE,WAAW,QACb,gBAAgB,IAAI,EAAE,MAAM,KAC5B,CAAC,YAAY,EAAE,MAAM,KACrB,EAAE,IAAI,SAAS;AAAA,IACnB,EACC,QAAQ,CAAC,MAAM;AACd,YAAM,OAAO,SAAS,EAAE,IAAI;AAK5B,UAAI,KAAK,WAAW,GAAG;AACrB,gBAAQ;AAAA,UACN,yBAAyB,EAAE,IAAI,UAAU,EAAE,EAAE;AAAA,QAC/C;AACA,eAAO,CAAC;AAAA,MACV;AACA,YAAM,OAAa;AAAA,QACjB,MAAM,GAAG,OAAO,IAAI,IAAI;AAAA,QACxB,MAAM;AAAA,QACN,MAAM,EAAE,eAAe,EAAE,IAAI,aAAa,EAAE,KAAK;AAAA,MACnD;AAKA,UAAI,UAAU,EAAE,GAAG,GAAG;AACpB,aAAK,cAAc,EAAE;AAAA,MACvB,OAAO;AACL,gBAAQ;AAAA,UACN,4CAA4C,EAAE,IAAI,0BAA0B,KAAK,UAAU,EAAE,GAAG,CAAC;AAAA,QACnG;AAAA,MACF;AACA,UAAI,EAAE,QAAS,MAAK,UAAU,EAAE;AAIhC,UAAI,EAAE,UAAW,MAAK,YAAY,EAAE;AACpC,aAAO,CAAC,IAAI;AAAA,IACd,CAAC;AAAA,EACL;AACF;","names":[]}
|
|
@@ -261,8 +261,8 @@ function testingIntroSection(copy) {
|
|
|
261
261
|
return `
|
|
262
262
|
<mj-section background-color="#F4F4F4">
|
|
263
263
|
<mj-column>
|
|
264
|
-
<mj-text color="
|
|
265
|
-
<mj-text color="
|
|
264
|
+
<mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="75px">TESTING</mj-text>
|
|
265
|
+
<mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml2(copy.testingIntro)}</mj-text>
|
|
266
266
|
</mj-column>
|
|
267
267
|
</mj-section>`;
|
|
268
268
|
}
|
|
@@ -270,8 +270,8 @@ function commentarySection(text, copy) {
|
|
|
270
270
|
return `
|
|
271
271
|
<mj-section background-color="white">
|
|
272
272
|
<mj-column>
|
|
273
|
-
<mj-text color="
|
|
274
|
-
<mj-text color="
|
|
273
|
+
<mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="55px">${escapeXml2(copy.notesHeader)}</mj-text>
|
|
274
|
+
<mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml2(text).replace(/\r\n?|\n/g, "<br/>")}</mj-text>
|
|
275
275
|
</mj-column>
|
|
276
276
|
</mj-section>`;
|
|
277
277
|
}
|
|
@@ -313,10 +313,10 @@ function buildMjml(data) {
|
|
|
313
313
|
</mj-section>
|
|
314
314
|
<mj-section background-color="white">
|
|
315
315
|
<mj-column>
|
|
316
|
-
<mj-text color="
|
|
317
|
-
<mj-text color="
|
|
318
|
-
<mj-text color="
|
|
319
|
-
<mj-text color="
|
|
316
|
+
<mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="75px">COMPLETED ON</mj-text>
|
|
317
|
+
<mj-text color="${RED}" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
|
|
318
|
+
<mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="75px">MAINTENANCE CHECKS</mj-text>
|
|
319
|
+
<mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml2(copy.maintenanceIntro)}</mj-text>
|
|
320
320
|
</mj-column>
|
|
321
321
|
</mj-section>
|
|
322
322
|
${maintenanceChecksSection(copy, data.searchPosition)}
|
|
@@ -332,17 +332,17 @@ function buildMjml(data) {
|
|
|
332
332
|
${data.commentary ? commentarySection(data.commentary, copy) : ""}
|
|
333
333
|
<mj-section background-color="white">
|
|
334
334
|
<mj-column padding-top="36px">
|
|
335
|
-
<mj-text color="
|
|
335
|
+
<mj-text color="${RED}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="36px" line-height="36px">Any questions, concerns or requests?</mj-text>
|
|
336
336
|
${copy.contact.map((line, i) => {
|
|
337
337
|
const isLast = i === copy.contact.length - 1;
|
|
338
|
-
const emphasis = i === 0 ? `color="
|
|
338
|
+
const emphasis = i === 0 ? `color="${RED}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700"` : `color="${GREY}" font-family="helvetica, sans-serif" font-size="24px" font-weight="300"`;
|
|
339
339
|
return isLast ? `<mj-text ${emphasis} padding-top="0px" line-height="30px" padding-bottom="36px">${escapeXml2(line)}</mj-text>` : `<mj-text ${emphasis} line-height="30px">${escapeXml2(line)}</mj-text>`;
|
|
340
340
|
}).join("\n ")}
|
|
341
341
|
<mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
|
|
342
|
-
<mj-text color="
|
|
343
|
-
<mj-text color="
|
|
342
|
+
<mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" line-height="20px" font-style="italic">Copyright ${(/* @__PURE__ */ new Date()).getUTCFullYear()} ${escapeXml2(copy.footerOrg)}. All rights reserved.</mj-text>
|
|
343
|
+
<mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="700" line-height="16px" padding-top="0" padding-bottom="0px">Our mailing address is:</mj-text>
|
|
344
344
|
${[copy.footerOrg, ...copy.footerAddress].map(
|
|
345
|
-
(line) => `<mj-text color="
|
|
345
|
+
(line) => `<mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml2(line)}</mj-text>`
|
|
346
346
|
).join("\n ")}
|
|
347
347
|
</mj-column>
|
|
348
348
|
</mj-section>
|
|
@@ -587,8 +587,32 @@ function checklistFor(type) {
|
|
|
587
587
|
if (type === "Testing") return [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST];
|
|
588
588
|
return [];
|
|
589
589
|
}
|
|
590
|
-
|
|
591
|
-
|
|
590
|
+
var MAINTENANCE_GATING_FIELDS = [
|
|
591
|
+
"Maint: Deploy & Function Health",
|
|
592
|
+
"Maint: CMS Checked",
|
|
593
|
+
"Maint: Domain, DNS & SSL",
|
|
594
|
+
"Maint: Security Updates",
|
|
595
|
+
"Maint: Uptime Checked"
|
|
596
|
+
];
|
|
597
|
+
function gatingFields(type) {
|
|
598
|
+
if (type === "Maintenance") return MAINTENANCE_GATING_FIELDS;
|
|
599
|
+
if (type === "Testing") return checklistFor("Testing").map((i) => i.field);
|
|
600
|
+
return [];
|
|
601
|
+
}
|
|
602
|
+
function isHealthGateClear(report) {
|
|
603
|
+
return gatingFields(report.reportType).every((field) => {
|
|
604
|
+
const r = report.autoEvidence[field]?.result;
|
|
605
|
+
return r === "pass" || r === "n/a";
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
function gatingHealth(report) {
|
|
609
|
+
return gatingFields(report.reportType).map((field) => ({
|
|
610
|
+
field,
|
|
611
|
+
status: report.autoEvidence[field]?.result ?? "unknown"
|
|
612
|
+
}));
|
|
613
|
+
}
|
|
614
|
+
function isSendOverridden(report) {
|
|
615
|
+
return report.sendOverride && (report.overrideReason ?? "").trim() !== "";
|
|
592
616
|
}
|
|
593
617
|
|
|
594
618
|
// src/reports/airtable/reports.ts
|
|
@@ -633,7 +657,11 @@ function mapRow(rec) {
|
|
|
633
657
|
renderedHtmlAttachment: html,
|
|
634
658
|
resendMessageId: f["Resend message ID"] ?? null,
|
|
635
659
|
checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])])),
|
|
636
|
-
autoEvidence: parseAutoEvidence(f["Checklist auto-evidence"])
|
|
660
|
+
autoEvidence: parseAutoEvidence(f["Checklist auto-evidence"]),
|
|
661
|
+
sendOverride: Boolean(f["Send override"]),
|
|
662
|
+
overrideReason: f["Override reason"] ?? null,
|
|
663
|
+
overrideBy: f["Override by"] ?? null,
|
|
664
|
+
overrideAt: f["Override at"] ?? null
|
|
637
665
|
};
|
|
638
666
|
}
|
|
639
667
|
function parseAutoEvidence(raw) {
|
|
@@ -649,7 +677,8 @@ function parseAutoEvidence(raw) {
|
|
|
649
677
|
for (const [field, v] of Object.entries(parsed)) {
|
|
650
678
|
if (!v || typeof v !== "object") continue;
|
|
651
679
|
const o = v;
|
|
652
|
-
if (o.result !== "pass" && o.result !== "fail" && o.result !== "unknown")
|
|
680
|
+
if (o.result !== "pass" && o.result !== "fail" && o.result !== "unknown" && o.result !== "n/a")
|
|
681
|
+
continue;
|
|
653
682
|
out[field] = {
|
|
654
683
|
result: o.result,
|
|
655
684
|
checkedAt: typeof o.checkedAt === "string" ? o.checkedAt : null,
|
|
@@ -824,7 +853,10 @@ export {
|
|
|
824
853
|
announcementSiteExtras,
|
|
825
854
|
renderReportHtml,
|
|
826
855
|
checklistFor,
|
|
827
|
-
|
|
856
|
+
gatingFields,
|
|
857
|
+
isHealthGateClear,
|
|
858
|
+
gatingHealth,
|
|
859
|
+
isSendOverridden,
|
|
828
860
|
isPendingApproval,
|
|
829
861
|
createDraft,
|
|
830
862
|
setDraftReady,
|
|
@@ -837,4 +869,4 @@ export {
|
|
|
837
869
|
fetchAttachmentBytes,
|
|
838
870
|
uploadAttachment
|
|
839
871
|
};
|
|
840
|
-
//# sourceMappingURL=chunk-
|
|
872
|
+
//# sourceMappingURL=chunk-JCITWZQR.js.map
|