@reddoorla/maintenance 0.75.1 → 0.78.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 +34 -7
- package/dist/{announce-V6EYPPUR.js → announce-RUT247HS.js} +9 -6
- package/dist/{announce-V6EYPPUR.js.map → announce-RUT247HS.js.map} +1 -1
- package/dist/{blux-C24OVDDW.js → blux-2P4FGCUL.js} +1867 -277
- package/dist/blux-2P4FGCUL.js.map +1 -0
- package/dist/{bump-deps-OQR3IXLN.js → bump-deps-XB2LJTVN.js} +3 -3
- package/dist/{chunk-RHPDQW6D.js → chunk-2SLED3NB.js} +1 -1
- package/dist/{chunk-RHPDQW6D.js.map → chunk-2SLED3NB.js.map} +1 -1
- package/dist/chunk-37S6L6G4.js +170 -0
- package/dist/chunk-37S6L6G4.js.map +1 -0
- package/dist/{chunk-HIN3P4ZN.js → chunk-4SOCPK7A.js} +10 -1
- package/dist/chunk-4SOCPK7A.js.map +1 -0
- package/dist/{run-migration-LJNNZWIV.js → chunk-6DOFNDXU.js} +5 -3
- package/dist/chunk-6DOFNDXU.js.map +1 -0
- package/dist/{chunk-NUIBQPB3.js → chunk-6SZBNALD.js} +62 -9
- package/dist/chunk-6SZBNALD.js.map +1 -0
- package/dist/{chunk-CASCWQQX.js → chunk-73UFRZKY.js} +34 -19
- package/dist/chunk-73UFRZKY.js.map +1 -0
- package/dist/{chunk-D3DQFC32.js → chunk-ATJ6ZA2V.js} +2 -2
- package/dist/{chunk-ZO7WNY57.js → chunk-B5ZUQZFL.js} +2 -2
- package/dist/{chunk-PH3AGF4Z.js → chunk-DC5H7KEQ.js} +2 -2
- package/dist/chunk-DRDAEELJ.js +197 -0
- package/dist/chunk-DRDAEELJ.js.map +1 -0
- package/dist/{chunk-IDBMYDVH.js → chunk-FVVGDIGX.js} +6 -4
- package/dist/{chunk-IDBMYDVH.js.map → chunk-FVVGDIGX.js.map} +1 -1
- package/dist/{chunk-7MJBTV2R.js → chunk-FX7RAMGC.js} +2 -2
- package/dist/{chunk-2AUUAJ5X.js → chunk-GWAOX3CU.js} +9 -9
- package/dist/{chunk-PKVZMFEB.js → chunk-K5JZ5KLO.js} +2 -2
- package/dist/{chunk-6OYICQSJ.js → chunk-KPQXB5CR.js} +9 -4
- package/dist/chunk-KPQXB5CR.js.map +1 -0
- package/dist/{chunk-BTEBRTW4.js → chunk-LS6QPANO.js} +2 -2
- package/dist/{chunk-2X322ZES.js → chunk-MGJ72BA4.js} +2 -2
- package/dist/chunk-MNDIED4M.js +137 -0
- package/dist/chunk-MNDIED4M.js.map +1 -0
- package/dist/{chunk-JCITWZQR.js → chunk-MNE374CZ.js} +2 -50
- package/dist/chunk-MNE374CZ.js.map +1 -0
- package/dist/chunk-MPBXOJ3N.js +51 -0
- package/dist/chunk-MPBXOJ3N.js.map +1 -0
- package/dist/{chunk-ZYBOT5PM.js → chunk-NT4WIP7I.js} +2 -2
- package/dist/{chunk-JNXCDUYB.js → chunk-Q276MZ7E.js} +3 -4
- package/dist/chunk-Q276MZ7E.js.map +1 -0
- package/dist/{chunk-GYNRZCM6.js → chunk-QG6OSL4M.js} +2 -2
- package/dist/{chunk-IRRZG66U.js → chunk-QMXQUL5Z.js} +34 -18
- package/dist/chunk-QMXQUL5Z.js.map +1 -0
- package/dist/{chunk-I55YWV6T.js → chunk-QWFHC3VH.js} +3 -3
- package/dist/{chunk-KLVMJYZZ.js → chunk-SEQA7MC3.js} +2 -2
- package/dist/chunk-UWJZXL2D.js +9 -0
- package/dist/chunk-UWJZXL2D.js.map +1 -0
- package/dist/{chunk-YEGHUODU.js → chunk-VSHC4HOI.js} +96 -1
- package/dist/chunk-VSHC4HOI.js.map +1 -0
- package/dist/{chunk-HBFMTM2E.js → chunk-YIQ7IWE3.js} +3 -3
- package/dist/{chunk-TDG5Z5OT.js → chunk-YSPA5AUT.js} +86 -13
- package/dist/chunk-YSPA5AUT.js.map +1 -0
- package/dist/{chunk-XKOZUVK2.js → chunk-ZTPPCHDB.js} +1 -1
- package/dist/chunk-ZTPPCHDB.js.map +1 -0
- package/dist/cli/bin.js +62 -24
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +1 -1
- package/dist/cli/commands/audit.js +5 -5
- package/dist/client-X5WUJWDE.js +11 -0
- package/dist/configs/eslint.js +10 -0
- package/dist/configs/eslint.js.map +1 -1
- package/dist/{convert-to-pnpm-OVMASATZ.js → convert-to-pnpm-FZHSLUQB.js} +3 -3
- package/dist/{db-3QW2XEXA.js → db-JSIZYT23.js} +3 -3
- package/dist/{digest-LEMZS5ZK.js → digest-NQAQI34P.js} +16 -13
- package/dist/{digest-LEMZS5ZK.js.map → digest-NQAQI34P.js.map} +1 -1
- package/dist/favicon-SRHSJZ62.js +42 -0
- package/dist/favicon-SRHSJZ62.js.map +1 -0
- package/dist/forms/index.js.map +1 -1
- package/dist/freeze-Y5OJFOST.js +442 -0
- package/dist/freeze-Y5OJFOST.js.map +1 -0
- package/dist/{github-signals-TF3VSNIN.js → github-signals-4LWQGD5V.js} +5 -5
- package/dist/header-image-H4N54DHN.js +95 -0
- package/dist/header-image-H4N54DHN.js.map +1 -0
- package/dist/{health-endpoint-JZGYCMTV.js → health-endpoint-LFVE26QS.js} +3 -3
- package/dist/index.d.ts +65 -3
- package/dist/index.js +86 -31
- package/dist/index.js.map +1 -1
- package/dist/{init-Y2DMKKE7.js → init-KF4QHMWQ.js} +12 -12
- package/dist/{launch-ZDX7UA3Y.js → launch-CVCIP5NU.js} +11 -8
- package/dist/launch-CVCIP5NU.js.map +1 -0
- package/dist/migrate-LJUFGXQZ.js +7 -0
- package/dist/{onboard-PX36YDZA.js → onboard-RDGHVCNG.js} +3 -3
- package/dist/{orchestrate-NYRYEKQM.js → orchestrate-QOQXOJLI.js} +7 -6
- package/dist/{preflight-JCQFKB6M.js → preflight-2PWVVIRR.js} +8 -7
- package/dist/{preflight-JCQFKB6M.js.map → preflight-2PWVVIRR.js.map} +1 -1
- package/dist/protection-audit-X3BHT2MB.js +140 -0
- package/dist/protection-audit-X3BHT2MB.js.map +1 -0
- package/dist/recipes/sync-configs.d.ts +1 -1
- package/dist/recipes/sync-configs.js +3 -3
- package/dist/{renovate-dispatch-6II2JVOX.js → renovate-dispatch-2VYCEH42.js} +2 -2
- package/dist/renovate-dispatch-2VYCEH42.js.map +1 -0
- package/dist/{report-POHQEV6F.js → report-IU4CLJS5.js} +8 -6
- package/dist/{report-POHQEV6F.js.map → report-IU4CLJS5.js.map} +1 -1
- package/dist/reports/header-image/assets/plate.png +0 -0
- package/dist/run-migration-WP57U5XO.js +9 -0
- package/dist/run-migration-WP57U5XO.js.map +1 -0
- package/dist/{self-updating-2YSVNMPP.js → self-updating-ALMUWJNX.js} +5 -4
- package/dist/{self-updating-2YSVNMPP.js.map → self-updating-ALMUWJNX.js.map} +1 -1
- package/dist/{selftest-OO2WDSDM.js → selftest-PJFABZDM.js} +11 -9
- package/dist/{selftest-OO2WDSDM.js.map → selftest-PJFABZDM.js.map} +1 -1
- package/dist/{smoke-suite-CSP23NZM.js → smoke-suite-SUEJFBYP.js} +3 -3
- package/dist/{submissions-MDKWJYMM.js → submissions-3I3GAQAX.js} +13 -5
- package/dist/submissions-3I3GAQAX.js.map +1 -0
- package/dist/{submissions-BNRGY3JQ.js → submissions-SURN7BV5.js} +3 -3
- package/dist/{svelte-codemods-V54KOBWU.js → svelte-codemods-IWO7X3F3.js} +3 -3
- package/dist/{sync-configs-UBCJRASZ.js → sync-configs-7XGHFC6P.js} +4 -4
- package/dist/{types-0jreMCF5.d.ts → types-DnEL3NRn.d.ts} +1 -1
- package/dist/{upgrade-WR6ICPAG.js → upgrade-AM4S7JF3.js} +3 -3
- package/dist/webflow-624FEBFD.js +548 -0
- package/dist/webflow-624FEBFD.js.map +1 -0
- package/package.json +3 -3
- package/dist/blux-C24OVDDW.js.map +0 -1
- package/dist/chunk-6OYICQSJ.js.map +0 -1
- package/dist/chunk-CASCWQQX.js.map +0 -1
- package/dist/chunk-HIN3P4ZN.js.map +0 -1
- package/dist/chunk-IRRZG66U.js.map +0 -1
- package/dist/chunk-JCITWZQR.js.map +0 -1
- package/dist/chunk-JNXCDUYB.js.map +0 -1
- package/dist/chunk-NUIBQPB3.js.map +0 -1
- package/dist/chunk-TDG5Z5OT.js.map +0 -1
- package/dist/chunk-XKOZUVK2.js.map +0 -1
- package/dist/chunk-YEGHUODU.js.map +0 -1
- package/dist/client-NVVQK3HH.js +0 -11
- package/dist/launch-ZDX7UA3Y.js.map +0 -1
- package/dist/migrate-35DNNF3H.js +0 -7
- package/dist/renovate-dispatch-6II2JVOX.js.map +0 -1
- package/dist/run-migration-LJNNZWIV.js.map +0 -1
- package/dist/submissions-MDKWJYMM.js.map +0 -1
- /package/dist/{bump-deps-OQR3IXLN.js.map → bump-deps-XB2LJTVN.js.map} +0 -0
- /package/dist/{chunk-D3DQFC32.js.map → chunk-ATJ6ZA2V.js.map} +0 -0
- /package/dist/{chunk-ZO7WNY57.js.map → chunk-B5ZUQZFL.js.map} +0 -0
- /package/dist/{chunk-PH3AGF4Z.js.map → chunk-DC5H7KEQ.js.map} +0 -0
- /package/dist/{chunk-7MJBTV2R.js.map → chunk-FX7RAMGC.js.map} +0 -0
- /package/dist/{chunk-2AUUAJ5X.js.map → chunk-GWAOX3CU.js.map} +0 -0
- /package/dist/{chunk-PKVZMFEB.js.map → chunk-K5JZ5KLO.js.map} +0 -0
- /package/dist/{chunk-BTEBRTW4.js.map → chunk-LS6QPANO.js.map} +0 -0
- /package/dist/{chunk-2X322ZES.js.map → chunk-MGJ72BA4.js.map} +0 -0
- /package/dist/{chunk-ZYBOT5PM.js.map → chunk-NT4WIP7I.js.map} +0 -0
- /package/dist/{chunk-GYNRZCM6.js.map → chunk-QG6OSL4M.js.map} +0 -0
- /package/dist/{chunk-I55YWV6T.js.map → chunk-QWFHC3VH.js.map} +0 -0
- /package/dist/{chunk-KLVMJYZZ.js.map → chunk-SEQA7MC3.js.map} +0 -0
- /package/dist/{chunk-HBFMTM2E.js.map → chunk-YIQ7IWE3.js.map} +0 -0
- /package/dist/{client-NVVQK3HH.js.map → client-X5WUJWDE.js.map} +0 -0
- /package/dist/{convert-to-pnpm-OVMASATZ.js.map → convert-to-pnpm-FZHSLUQB.js.map} +0 -0
- /package/dist/{db-3QW2XEXA.js.map → db-JSIZYT23.js.map} +0 -0
- /package/dist/{github-signals-TF3VSNIN.js.map → github-signals-4LWQGD5V.js.map} +0 -0
- /package/dist/{health-endpoint-JZGYCMTV.js.map → health-endpoint-LFVE26QS.js.map} +0 -0
- /package/dist/{init-Y2DMKKE7.js.map → init-KF4QHMWQ.js.map} +0 -0
- /package/dist/{migrate-35DNNF3H.js.map → migrate-LJUFGXQZ.js.map} +0 -0
- /package/dist/{onboard-PX36YDZA.js.map → onboard-RDGHVCNG.js.map} +0 -0
- /package/dist/{orchestrate-NYRYEKQM.js.map → orchestrate-QOQXOJLI.js.map} +0 -0
- /package/dist/{smoke-suite-CSP23NZM.js.map → smoke-suite-SUEJFBYP.js.map} +0 -0
- /package/dist/{submissions-BNRGY3JQ.js.map → submissions-SURN7BV5.js.map} +0 -0
- /package/dist/{svelte-codemods-V54KOBWU.js.map → svelte-codemods-IWO7X3F3.js.map} +0 -0
- /package/dist/{sync-configs-UBCJRASZ.js.map → sync-configs-7XGHFC6P.js.map} +0 -0
- /package/dist/{upgrade-WR6ICPAG.js.map → upgrade-AM4S7JF3.js.map} +0 -0
|
@@ -1 +1 @@
|
|
|
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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmBA,IAAM,mBAAmB;AAUlB,SAAS,qBAAqB,GAAuC;AAC1E,QAAM,EAAE,iBAAiB,gBAAgB,IAAI;AAC7C,QAAM,OACJ,mBAAmB,KACnB,mBAAmB,oBACnB,kBAAkB,KAAK;AACzB,QAAM,SAAS,OACX,GAAG,eAAe,OAAO,eAAe,oPACxC;AACJ,SAAO,EAAE,MAAM,OAAO;AACxB;AAOO,SAAS,2BACd,GACA,cACmC;AACnC,QAAM,UAAU,oDAA0C,EAAE,eAAe,IAAI,EAAE,eAAe;AAChG,QAAM,EAAE,OAAO,IAAI,qBAAqB,CAAC;AACzC,QAAM,OAAO,cAAc,WAAW,MAAM,CAAC;AAAA;AAAA;AAAA,cAGjC,WAAW,YAAY,CAAC;AACpC,SAAO,EAAE,SAAS,KAAK;AACzB;;;ACvBA,SAAS,UACP,UACA,QACA,eACA,OAAO,WACC;AACR,QAAM,KAAK,YAAY;AACvB,MAAI,WAAW,OAAO;AACpB,WAAO,UAAK,IAAI,oBAAoB,EAAE;AAAA,EACxC;AACA,QAAM,MACJ,cAAc,SAAS,IACnB,gBAAgB,cAAc,MAAM,oBAAoB,cAAc,SAAS,IAAI,MAAM,EAAE,MAC3F;AACN,SAAO,UAAK,IAAI,KAAK,EAAE,GAAG,GAAG;AAC/B;AASO,SAAS,0BAA0B,KAAqC;AAC7E,MAAI,QAAQ,UAAa,IAAI,KAAK,MAAM,GAAI,QAAO;AACnD,QAAM,OAAO,IAAI,KAAK,EAAE,YAAY;AACpC,MAAI,SAAS,cAAe,QAAO;AACnC,MAAI,SAAS,UAAW,QAAO;AAC/B,QAAM,OACJ,SAAS,WACL,4CACA,SAAS,iBACP,8CACA;AACR,QAAM,OAAO;AAAA,IACX,IAAI,MAAM,8CAA8C,KAAK,UAAU,GAAG,CAAC,IAAI,IAAI,EAAE;AAAA,IACrF,EAAE,UAAU,EAAE;AAAA,EAChB;AACF;AAIA,SAAS,mBAA2B;AAClC,SAAO,QAAQ,IAAI,oBAAoB,KAAK,KAAK;AACnD;AAEA,eAAsB,iBACpB,MACA,MAC2C;AAC3C,MAAI,KAAK,QAAQ;AACf,UAAM,EAAE,UAAU,IAAI,MAAM,OAAO,sBAAyB;AAC5D,WAAO,UAAU,EAAE,SAAS,iBAAiB,EAAE,CAAC;AAAA,EAClD;AAEA,MAAI,KAAK,WAAW;AAClB,UAAM,EAAE,oBAAoB,IAAI,MAAM,OAAO,2BAAmC;AAChF,WAAO,oBAAoB;AAAA,EAC7B;AAEA,MAAI,KAAK,KAAK;AACZ,WAAO,YAAY;AAAA,EACrB;AAEA,MAAI,MAAM;AAGR,UAAM,aAAa,0BAA0B,KAAK,IAAI;AACtD,WAAO,mBAAmB,MAAM,EAAE,aAAa,QAAQ,KAAK,OAAO,GAAG,WAAW,CAAC;AAAA,EACpF;AAEA,QAAM,OAAO;AAAA,IACX,IAAI;AAAA,MACF;AAAA,IACF;AAAA,IACA;AAAA,MACE,UAAU;AAAA,IACZ;AAAA,EACF;AACF;AAEA,eAAe,cAAyD;AACtE,QAAM,OAAO,SAAS,mBAAmB,CAAC;AAC1C,QAAM,SAAS,MAAM,gBAAgB,MAAM,oBAAI,KAAK,CAAC;AACrD,QAAM,6BAA6B,OAAO,MAAM;AAChD,SAAO,EAAE,QAAQ,OAAO,QAAQ,MAAM,OAAO,KAAK;AACpD;AAOA,eAAe,6BAA6B,QAA2C;AACrF,MAAI,CAAC,qBAAqB,MAAM,EAAE,KAAM;AACxC,MAAI;AACF,UAAM,KAAK,QAAQ,IAAI,gBAAgB,KAAK,KAAK;AACjD,UAAM,EAAE,SAAS,KAAK,IAAI,2BAA2B,QAAQ,iBAAiB,CAAC;AAC/E,UAAM,EAAE,oBAAoB,IAAI,MAAM,OAAO,sBAA8B;AAC3E,UAAM,oBAAoB,EAAE,KAAK;AAAA,MAC/B,MAAM;AAAA,MACN,IAAI,CAAC,EAAE;AAAA,MACP;AAAA,MACA;AAAA,MACA,gBAAgB,oBAAmB,oBAAI,KAAK,GAAE,YAAY,EAAE,MAAM,GAAG,EAAE,CAAC;AAAA,IAC1E,CAAC;AACD,YAAQ,KAAK,UAAK,OAAO,6BAAwB,EAAE,GAAG;AAAA,EACxD,SAAS,GAAG;AACV,YAAQ,KAAK,+CAA2C,EAAY,OAAO,EAAE;AAAA,EAC/E;AACF;AASA,eAAe,kBACb,MACA,UACA,SACA,OACe;AACf,QAAM,MAAM,CAAC,MAAmC,IAAI,EAAE,YAAY,EAAE,MAAM,GAAG,EAAE,IAAI;AACnF,aAAW,QAAQ,UAAU;AAC3B,QAAI;AACF,YAAM,mBAAmB,MAAM,KAAK,IAAI;AAAA,QACtC,eAAe,IAAI,YAAY,MAAM,SAAS,eAAe,KAAK,CAAC;AAAA,QACnE,WAAW,IAAI,YAAY,MAAM,SAAS,WAAW,KAAK,CAAC;AAAA,MAC7D,CAAC;AAAA,IACH,SAAS,GAAG;AACV,cAAQ,KAAK,qCAAgC,KAAK,IAAI,KAAM,EAAY,OAAO,EAAE;AAAA,IACnF;AAAA,EACF;AACF;AAEA,eAAsB,gBACpB,MACA,OACuE;AACvE,QAAM,WAAW,MAAM,aAAa,IAAI;AAIxC,QAAM,UAAU,MAAM,eAAe,IAAI;AAIzC,QAAM,kBAAkB,MAAM,UAAU,SAAS,KAAK;AAEtD,QAAM,MAAM,eAAe,UAAU,SAAS,KAAK;AAMnD,QAAM,eAAe,aAAa,MAAM;AAIxC,QAAM,wBAAwB,CAAC,MAC7B,gBAAgB,QAAQ,EAAE,iBAAiB,EAAE,WAAW;AAE1D,MAAI,IAAI,WAAW,GAAG;AACpB,WAAO;AAAA,MACL,QAAQ;AAAA,MACR,MAAM;AAAA,MACN,QAAQ,EAAE,iBAAiB,GAAG,iBAAiB,EAAE;AAAA,IACnD;AAAA,EACF;AAEA,QAAM,QAAkB,CAAC;AACzB,MAAI,kBAAkB;AACtB,MAAI,sBAAsB;AAC1B,MAAI,0BAA0B;AAC9B,MAAI,oBAAoB;AACxB,MAAI,UAAU;AACd,aAAW,QAAQ,KAAK;AAItB,UAAM,SAAS,gBAAgB,KAAK,OAAO;AAC3C,UAAM,WAAW,QAAQ;AAAA,MACvB,CAAC,MAAM,EAAE,WAAW,KAAK,KAAK,MAAM,EAAE,eAAe,KAAK,cAAc,EAAE,WAAW;AAAA,IACvF;AAQA,QAAI,UAAU;AACZ,UAAI,SAAS,YAAY;AACvB;AACA,cAAM,KAAK,mCAA8B,MAAM,MAAM,KAAK,KAAK,IAAI,IAAI,KAAK,UAAU,EAAE;AACxF;AAAA,MACF;AAOA,YAAM,mBAAmB,QAAQ;AAAA,QAC/B,CAAC,MACC,EAAE,WAAW,KAAK,KAAK,MACvB,EAAE,OAAO,SAAS,MAClB,EAAE,WAAW,QACb,EAAE,cACF,WAAW,EAAE,UAAU,KAAK,WAAW,KAAK,UAAU;AAAA,MAC1D;AACA,UAAI,kBAAkB;AACpB;AACA,cAAM;AAAA,UACJ,gFAAsE,KAAK,KAAK,IAAI,IAAI,KAAK,UAAU;AAAA,QACzG;AACA;AAAA,MACF;AACA,UAAI;AACF,cAAM,SAAS,MAAM,mBAAmB,MAAM,KAAK,MAAM,KAAK,YAAY;AAAA,UACxE;AAAA,UACA,eAAe,SAAS;AAAA,UACxB,aAAa;AAAA,QACf,CAAC;AACD,iBAAS,aAAa,OAAO,WAAW;AACxC,cAAM;AAAA,UACJ;AAAA,YACE,OAAO,WAAW,YAAY,SAAS;AAAA,YACvC,OAAO;AAAA,YACP,OAAO;AAAA,YACP;AAAA,UACF;AAAA,QACF;AACA,YAAI,sBAAsB,KAAK,IAAI,EAAG;AACtC,YAAI,OAAO,aAAa,SAAS,EAAG;AACpC,YAAI,OAAO,oBAAqB;AAChC,YAAI,OAAO,sBAAuB;AAAA,MACpC,SAAS,GAAG;AACV,cAAM,KAAK,kBAAa,KAAK,KAAK,IAAI,IAAI,KAAK,UAAU,WAAO,EAAY,OAAO,EAAE;AAAA,MACvF;AACA;AAAA,IACF;AAYA,UAAM,iBAAiB,QAAQ;AAAA,MAC7B,CAAC,MACC,EAAE,WAAW,KAAK,KAAK,MACvB,EAAE,eAAe,KAAK,cACtB,EAAE,cACF,EAAE,WAAW,QACb,EAAE,WAAW,QACb,EAAE,SAAS;AAAA,IACf;AACA,QAAI,gBAAgB;AAClB;AACA,YAAM;AAAA,QACJ,mBAAc,KAAK,KAAK,IAAI,IAAI,KAAK,UAAU,0BAA0B,eAAe,MAAM;AAAA,MAChG;AACA;AAAA,IACF;AAEA,QAAI;AAIF,YAAM,SAAS,MAAM,mBAAmB,MAAM,KAAK,MAAM,KAAK,YAAY,EAAE,OAAO,CAAC;AACpF,YAAM,KAAK,UAAU,OAAO,WAAW,UAAU,OAAO,QAAQ,OAAO,aAAa,CAAC;AAIrF,UAAI,OAAO,UAAW,SAAQ,KAAK,OAAO,SAAS;AAGnD,UAAI,sBAAsB,KAAK,IAAI,EAAG;AACtC,UAAI,OAAO,aAAa,SAAS,EAAG;AACpC,UAAI,OAAO,oBAAqB;AAChC,UAAI,OAAO,sBAAuB;AAAA,IACpC,SAAS,GAAG;AACV,YAAM,KAAK,kBAAa,KAAK,KAAK,IAAI,IAAI,KAAK,UAAU,WAAO,EAAY,OAAO,EAAE;AAAA,IACvF;AAAA,EACF;AACA,MAAI,UAAU,GAAG;AACf,UAAM,KAAK,UAAK,OAAO,yCAAyC;AAAA,EAClE;AACA,MAAI,kBAAkB,GAAG;AACvB,UAAM;AAAA,MACJ,UAAK,eAAe,QAAQ,oBAAoB,IAAI,KAAK,GAAG;AAAA,IAC9D;AAAA,EACF;AACA,MAAI,sBAAsB,GAAG;AAC3B,UAAM;AAAA,MACJ,UAAK,mBAAmB,QAAQ,wBAAwB,IAAI,KAAK,GAAG;AAAA,IACtE;AAAA,EACF;AACA,MAAI,0BAA0B,GAAG;AAC/B,UAAM;AAAA,MACJ,UAAK,uBAAuB,QAAQ,4BAA4B,IAAI,KAAK,GAAG;AAAA,IAC9E;AAAA,EACF;AACA,SAAO;AAAA,IACL,QAAQ,MAAM,KAAK,IAAI;AAAA,IACvB,MAAM,MAAM,KAAK,CAAC,MAAM,EAAE,WAAW,QAAG,CAAC,IAAI,IAAI;AAAA,IACjD,QAAQ,EAAE,iBAAiB,iBAAiB,kBAAkB;AAAA,EAChE;AACF;AAEA,eAAe,mBACb,MACA,MAC2C;AAC3C,QAAM,OAAO,SAAS,mBAAmB,CAAC;AAC1C,QAAM,WAAW,MAAM,aAAa,IAAI;AACxC,QAAM,OAAO,SAAS,KAAK,CAAC,MAAM,SAAS,EAAE,IAAI,MAAM,IAAI;AAC3D,MAAI,CAAC,MAAM;AACT,UAAM,OAAO,OAAO,IAAI,MAAM,iCAAiC,IAAI,GAAG,GAAG,EAAE,UAAU,EAAE,CAAC;AAAA,EAC1F;AACA,QAAM,SAAS,MAAM,mBAAmB,KAAK,cAAc,OAAO,MAAM,MAAM,KAAK,YAAY;AAAA,IAC7F,aAAa,KAAK;AAAA,EACpB,CAAC;AACD,MAAI,KAAK,aAAa;AACpB,WAAO,EAAE,QAAQ,sBAAsB,OAAO,QAAQ,IAAI,MAAM,EAAE;AAAA,EACpE;AACA,SAAO;AAAA,IACL,QAAQ;AAAA,MACN,OAAO,WAAW;AAAA,MAClB,OAAO;AAAA,MACP,OAAO;AAAA,MACP;AAAA,IACF;AAAA,IACA,MAAM;AAAA,EACR;AACF;","names":[]}
|
|
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":[]}
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
selfUpdating
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-QMXQUL5Z.js";
|
|
4
|
+
import "./chunk-MNDIED4M.js";
|
|
4
5
|
import {
|
|
5
6
|
runRecipeOverSites
|
|
6
7
|
} from "./chunk-VPJ7N6P7.js";
|
|
@@ -12,9 +13,9 @@ import {
|
|
|
12
13
|
import {
|
|
13
14
|
resolveSites
|
|
14
15
|
} from "./chunk-4BHNP6Y2.js";
|
|
15
|
-
import "./chunk-
|
|
16
|
+
import "./chunk-73UFRZKY.js";
|
|
16
17
|
import "./chunk-GASBX52O.js";
|
|
17
|
-
import "./chunk-
|
|
18
|
+
import "./chunk-VSHC4HOI.js";
|
|
18
19
|
import "./chunk-FDIFTIXC.js";
|
|
19
20
|
import "./chunk-HVOOCK6L.js";
|
|
20
21
|
import "./chunk-XXTZBPUY.js";
|
|
@@ -61,4 +62,4 @@ async function runSelfUpdatingCommand(site, opts) {
|
|
|
61
62
|
export {
|
|
62
63
|
runSelfUpdatingCommand
|
|
63
64
|
};
|
|
64
|
-
//# sourceMappingURL=self-updating-
|
|
65
|
+
//# sourceMappingURL=self-updating-ALMUWJNX.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/cli/commands/self-updating.ts"],"sourcesContent":["import { resolve } from \"node:path\";\nimport { resolveSites } from \"../fleet/resolve-sites.js\";\nimport { prepareFleetSites, appendSkipNotice, type SkippedSite } from \"../fleet/prepare-sites.js\";\nimport { runRecipeOverSites } from \"../fleet/run-recipe-over-sites.js\";\nimport { fleetWorkdir } from \"../../util/fleet-workdir.js\";\nimport { selfUpdating } from \"../../recipes/self-updating/index.js\";\nimport type { RecipeResult } from \"../../types.js\";\n\nexport type SelfUpdatingCommandOptions = {\n fleet?: string;\n workdir?: string;\n dry?: boolean;\n cwd?: string;\n};\n\nfunction formatResult(r: RecipeResult): string {\n if (r.status === \"noop\") return `[${r.site}] noop: ${r.notes ?? \"already self-updating\"}`;\n if (r.status === \"failed\") return `[${r.site}] failed: ${r.notes ?? \"\"}`;\n return `[${r.site}] applied\\n${r.notes ?? \"\"}`;\n}\n\nexport async function runSelfUpdatingCommand(\n site: string | undefined,\n opts: SelfUpdatingCommandOptions,\n): Promise<{ output: string; code: number }> {\n const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();\n\n let sites = await resolveSites({\n ...(site !== undefined ? { site } : {}),\n ...(opts.fleet !== undefined ? { fleet: opts.fleet } : {}),\n cwd,\n });\n\n let skipped: SkippedSite[] = [];\n if (opts.fleet) {\n const workdir = opts.workdir ?? fleetWorkdir();\n const prep = await prepareFleetSites(sites, { workdir });\n sites = prep.prepared;\n skipped = prep.skipped;\n }\n\n if (opts.dry) {\n return {\n output: appendSkipNotice(\n sites.map((s) => `[${s.name || s.path}] would enable self-updating`).join(\"\\n\"),\n skipped,\n ),\n code: 0,\n };\n }\n\n const results = await runRecipeOverSites(\"self-updating\", sites, (s) => selfUpdating(s));\n\n return {\n output: appendSkipNotice(results.map(formatResult).join(\"\\n\"), skipped),\n code: results.some((r) => r.status === \"failed\") ? 1 : 0,\n };\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"sources":["../src/cli/commands/self-updating.ts"],"sourcesContent":["import { resolve } from \"node:path\";\nimport { resolveSites } from \"../fleet/resolve-sites.js\";\nimport { prepareFleetSites, appendSkipNotice, type SkippedSite } from \"../fleet/prepare-sites.js\";\nimport { runRecipeOverSites } from \"../fleet/run-recipe-over-sites.js\";\nimport { fleetWorkdir } from \"../../util/fleet-workdir.js\";\nimport { selfUpdating } from \"../../recipes/self-updating/index.js\";\nimport type { RecipeResult } from \"../../types.js\";\n\nexport type SelfUpdatingCommandOptions = {\n fleet?: string;\n workdir?: string;\n dry?: boolean;\n cwd?: string;\n};\n\nfunction formatResult(r: RecipeResult): string {\n if (r.status === \"noop\") return `[${r.site}] noop: ${r.notes ?? \"already self-updating\"}`;\n if (r.status === \"failed\") return `[${r.site}] failed: ${r.notes ?? \"\"}`;\n return `[${r.site}] applied\\n${r.notes ?? \"\"}`;\n}\n\nexport async function runSelfUpdatingCommand(\n site: string | undefined,\n opts: SelfUpdatingCommandOptions,\n): Promise<{ output: string; code: number }> {\n const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();\n\n let sites = await resolveSites({\n ...(site !== undefined ? { site } : {}),\n ...(opts.fleet !== undefined ? { fleet: opts.fleet } : {}),\n cwd,\n });\n\n let skipped: SkippedSite[] = [];\n if (opts.fleet) {\n const workdir = opts.workdir ?? fleetWorkdir();\n const prep = await prepareFleetSites(sites, { workdir });\n sites = prep.prepared;\n skipped = prep.skipped;\n }\n\n if (opts.dry) {\n return {\n output: appendSkipNotice(\n sites.map((s) => `[${s.name || s.path}] would enable self-updating`).join(\"\\n\"),\n skipped,\n ),\n code: 0,\n };\n }\n\n const results = await runRecipeOverSites(\"self-updating\", sites, (s) => selfUpdating(s));\n\n return {\n output: appendSkipNotice(results.map(formatResult).join(\"\\n\"), skipped),\n code: results.some((r) => r.status === \"failed\") ? 1 : 0,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AAAA,SAAS,eAAe;AAexB,SAAS,aAAa,GAAyB;AAC7C,MAAI,EAAE,WAAW,OAAQ,QAAO,IAAI,EAAE,IAAI,WAAW,EAAE,SAAS,uBAAuB;AACvF,MAAI,EAAE,WAAW,SAAU,QAAO,IAAI,EAAE,IAAI,aAAa,EAAE,SAAS,EAAE;AACtE,SAAO,IAAI,EAAE,IAAI;AAAA,EAAc,EAAE,SAAS,EAAE;AAC9C;AAEA,eAAsB,uBACpB,MACA,MAC2C;AAC3C,QAAM,MAAM,KAAK,MAAM,QAAQ,KAAK,GAAG,IAAI,QAAQ,IAAI;AAEvD,MAAI,QAAQ,MAAM,aAAa;AAAA,IAC7B,GAAI,SAAS,SAAY,EAAE,KAAK,IAAI,CAAC;AAAA,IACrC,GAAI,KAAK,UAAU,SAAY,EAAE,OAAO,KAAK,MAAM,IAAI,CAAC;AAAA,IACxD;AAAA,EACF,CAAC;AAED,MAAI,UAAyB,CAAC;AAC9B,MAAI,KAAK,OAAO;AACd,UAAM,UAAU,KAAK,WAAW,aAAa;AAC7C,UAAM,OAAO,MAAM,kBAAkB,OAAO,EAAE,QAAQ,CAAC;AACvD,YAAQ,KAAK;AACb,cAAU,KAAK;AAAA,EACjB;AAEA,MAAI,KAAK,KAAK;AACZ,WAAO;AAAA,MACL,QAAQ;AAAA,QACN,MAAM,IAAI,CAAC,MAAM,IAAI,EAAE,QAAQ,EAAE,IAAI,8BAA8B,EAAE,KAAK,IAAI;AAAA,QAC9E;AAAA,MACF;AAAA,MACA,MAAM;AAAA,IACR;AAAA,EACF;AAEA,QAAM,UAAU,MAAM,mBAAmB,iBAAiB,OAAO,CAAC,MAAM,aAAa,CAAC,CAAC;AAEvF,SAAO;AAAA,IACL,QAAQ,iBAAiB,QAAQ,IAAI,YAAY,EAAE,KAAK,IAAI,GAAG,OAAO;AAAA,IACtE,MAAM,QAAQ,KAAK,CAAC,MAAM,EAAE,WAAW,QAAQ,IAAI,IAAI;AAAA,EACzD;AACF;","names":[]}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildReportDataForSite,
|
|
3
3
|
scoresFromRow
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
6
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-QWFHC3VH.js";
|
|
5
|
+
import "./chunk-6SZBNALD.js";
|
|
6
|
+
import "./chunk-SEQA7MC3.js";
|
|
7
|
+
import "./chunk-37S6L6G4.js";
|
|
7
8
|
import {
|
|
8
9
|
ELIGIBLE_STATUSES
|
|
9
10
|
} from "./chunk-D4FVAIEJ.js";
|
|
@@ -12,17 +13,18 @@ import {
|
|
|
12
13
|
parseAddresses,
|
|
13
14
|
prepareHeaderImage,
|
|
14
15
|
renderReportEmail
|
|
15
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-FVVGDIGX.js";
|
|
16
17
|
import "./chunk-GECESMVS.js";
|
|
18
|
+
import "./chunk-MNE374CZ.js";
|
|
17
19
|
import {
|
|
18
20
|
fetchAttachmentBytes
|
|
19
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-MPBXOJ3N.js";
|
|
20
22
|
import {
|
|
21
23
|
defaultResendClient
|
|
22
24
|
} from "./chunk-WEKL2HYJ.js";
|
|
23
|
-
import "./chunk-
|
|
24
|
-
import "./chunk-
|
|
25
|
-
import "./chunk-
|
|
25
|
+
import "./chunk-QG6OSL4M.js";
|
|
26
|
+
import "./chunk-DC5H7KEQ.js";
|
|
27
|
+
import "./chunk-4SOCPK7A.js";
|
|
26
28
|
import "./chunk-U5SZ4FOP.js";
|
|
27
29
|
import {
|
|
28
30
|
listWebsites,
|
|
@@ -159,4 +161,4 @@ async function runSelftestCommand(kind, site, opts) {
|
|
|
159
161
|
export {
|
|
160
162
|
runSelftestCommand
|
|
161
163
|
};
|
|
162
|
-
//# sourceMappingURL=selftest-
|
|
164
|
+
//# sourceMappingURL=selftest-PJFABZDM.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/recipes/selftest-email.ts","../src/cli/commands/selftest.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport { listWebsites, siteSlug } from \"../reports/airtable/websites.js\";\nimport { ELIGIBLE_STATUSES } from \"../reports/due.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { fetchAttachmentBytes } from \"../reports/airtable/attachments.js\";\nimport { prepareHeaderImage } from \"../reports/maintenance-email/header-image.js\";\nimport { buildReportDataForSite, scoresFromRow } from \"../reports/report-data.js\";\nimport { renderReportEmail } from \"../reports/send/render-email.js\";\nimport { defaultResendClient, type ResendClient } from \"../reports/send/resend.js\";\nimport { parseAddresses, isProbablyEmail } from \"../reports/send/orchestrate.js\";\nimport type { ReportType } from \"../reports/types.js\";\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\nconst REPLY_TO = \"info@reddoorla.com\";\n\nexport type SelftestEmailDeps = {\n /** Airtable handle (read-only here). Defaults to the live base from credentials. */\n base?: AirtableBase;\n /** Resend client. Defaults to the real client. */\n resend?: ResendClient;\n /** Single-site slug. Mutually exclusive with `all`. */\n site?: string;\n /** All `maintenance` sites (one email each). Mutually exclusive with `site`. */\n all?: boolean;\n /** Report type to preview. Default \"Announcement\". */\n type?: ReportType;\n /** Raw `--to` (comma- or newline-separated). Default: OPERATOR_EMAIL → info@reddoorla.com. */\n to?: string;\n /** Render only; write reports/<slug>/selftest-<type>.html, never send. */\n dryRun?: boolean;\n /** Single timestamp driving the window + completedOn. */\n now?: Date;\n};\n\nexport type SelftestEmailSiteResult =\n | { site: string; status: \"sent\" | \"dry-run\"; subject: string; recipients: string[] }\n | { site: string; status: \"skipped\"; reason: string }\n | { site: string; status: \"error\"; message: string };\n\nexport type SelftestEmailResult = { results: SelftestEmailSiteResult[] };\n\n/** Resolve the recipient list: explicit `--to` (validated) else the operator default. */\nfunction resolveRecipients(to: string | undefined): string[] {\n const operator = process.env.OPERATOR_EMAIL?.trim() || \"info@reddoorla.com\";\n const parsed = to ? parseAddresses(to) : null;\n const list = parsed ?? [operator];\n for (const addr of list) {\n if (!isProbablyEmail(addr)) {\n throw Object.assign(new Error(`--to has a malformed address: ${addr}`), { exitCode: 2 });\n }\n }\n return list;\n}\n\n/**\n * Send (or dry-render) a single report email per target site to the operator/`--to`, with NO\n * Airtable side effects (no draft, queue, or stamp). Mirrors the production render+send via the\n * shared `renderReportEmail` seam, so the preview matches a real send. One bad site never aborts\n * `--all` (per-site try/catch). Sites missing stored scores or a header image are skipped.\n */\nexport async function selftestEmail(deps: SelftestEmailDeps): Promise<SelftestEmailResult> {\n const base = deps.base ?? openBase(readAirtableConfig());\n const resend = deps.resend ?? defaultResendClient();\n const type: ReportType = deps.type ?? \"Announcement\";\n const now = deps.now ?? new Date();\n const recipients = resolveRecipients(deps.to);\n\n const websites = await listWebsites(base);\n let targets: WebsiteRow[];\n if (deps.all) {\n // The report-eligible set (maintenance + hosting), not a hard-coded \"maintenance\" —\n // the latter silently excluded hosting sites and implied a type↔status coupling that\n // doesn't exist (the requested --type drives the rendered template; single-site mode\n // applies no status filter at all).\n targets = websites.filter((w) => w.status !== null && ELIGIBLE_STATUSES.has(w.status));\n } else if (deps.site) {\n const wanted = siteSlug(deps.site);\n targets = websites.filter((w) => siteSlug(w.name) === wanted);\n } else {\n throw Object.assign(new Error(\"Provide a site slug or --all\"), { exitCode: 2 });\n }\n\n const results: SelftestEmailSiteResult[] = [];\n for (const w of targets) {\n try {\n const scores = scoresFromRow(w);\n if (!scores) {\n results.push({ site: w.name, status: \"skipped\", reason: \"missing Lighthouse scores\" });\n continue;\n }\n if (!w.headerImage) {\n results.push({ site: w.name, status: \"skipped\", reason: \"no Header image\" });\n continue;\n }\n const original = await fetchAttachmentBytes(w.headerImage.url);\n const header = await prepareHeaderImage(original.bytes);\n const slug = siteSlug(w.name);\n const reportData = await buildReportDataForSite(w, type, now, { scores, header });\n const { html, attachments, subject } = await renderReportEmail(reportData, {\n header,\n cidName: `${slug}-header`,\n });\n\n if (deps.dryRun) {\n const path = `reports/${slug}/selftest-${type.toLowerCase()}.html`;\n await mkdir(dirname(path), { recursive: true });\n await writeFile(path, html, \"utf-8\");\n results.push({ site: w.name, status: \"dry-run\", subject, recipients });\n continue;\n }\n\n await resend.send({\n from: FROM_ADDRESS,\n to: recipients,\n replyTo: REPLY_TO,\n subject,\n html,\n attachments,\n });\n results.push({ site: w.name, status: \"sent\", subject, recipients });\n } catch (err) {\n results.push({\n site: w.name,\n status: \"error\",\n message: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n return { results };\n}\n","import { selftestEmail, type SelftestEmailSiteResult } from \"../../recipes/selftest-email.js\";\nimport type { ReportType } from \"../../reports/types.js\";\n\nexport type SelftestCommandOptions = {\n type?: string;\n to?: string;\n all?: boolean;\n dryRun?: boolean;\n cwd?: string;\n};\n\nconst TYPES: Record<string, ReportType> = {\n announcement: \"Announcement\",\n maintenance: \"Maintenance\",\n testing: \"Testing\",\n launch: \"Launch\",\n};\n\nfunction formatResult(r: SelftestEmailSiteResult): string {\n if (r.status === \"skipped\") return `[${r.site}] skipped — ${r.reason}`;\n if (r.status === \"error\") return `[${r.site}] error: ${r.message}`;\n return `[${r.site}] ${r.status} — \"${r.subject}\" → ${r.recipients.join(\", \")}`;\n}\n\n/**\n * `selftest <kind> [site]` — operator self-tests. The only kind today is `email`: preview a\n * report email for one site (or `--all` maintenance sites) to the operator/`--to`, with no\n * Airtable side effects. Validates kind/type and the site-xor-all rule before doing any work.\n */\nexport async function runSelftestCommand(\n kind: string,\n site: string | undefined,\n opts: SelftestCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (kind !== \"email\") {\n return { output: `Unknown selftest kind '${kind}'. Supported: email`, code: 2 };\n }\n if (Boolean(site) === Boolean(opts.all)) {\n return { output: \"Provide exactly one of <site> or --all.\", code: 2 };\n }\n const typeKey = (opts.type ?? \"announcement\").toLowerCase();\n const type = TYPES[typeKey];\n if (!type) {\n return {\n output: `Unknown --type '${opts.type}'. Supported: ${Object.keys(TYPES).join(\", \")}`,\n code: 2,\n };\n }\n\n try {\n const { results } = await selftestEmail({\n ...(site ? { site } : {}),\n ...(opts.all ? { all: true } : {}),\n type,\n ...(opts.to ? { to: opts.to } : {}),\n ...(opts.dryRun ? { dryRun: true } : {}),\n });\n const output =\n results.length === 0 ? \"No matching sites.\" : results.map(formatResult).join(\"\\n\");\n const code = results.some((r) => r.status === \"error\") ? 1 : 0;\n return { output, code };\n } catch (err) {\n const e = err as { message?: string; exitCode?: number };\n return { output: e.message ?? String(err), code: e.exitCode ?? 1 };\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,SAAS,OAAO,iBAAiB;AACjC,SAAS,eAAe;AAcxB,IAAM,eAAe;AACrB,IAAM,WAAW;AA6BjB,SAAS,kBAAkB,IAAkC;AAC3D,QAAM,WAAW,QAAQ,IAAI,gBAAgB,KAAK,KAAK;AACvD,QAAM,SAAS,KAAK,eAAe,EAAE,IAAI;AACzC,QAAM,OAAO,UAAU,CAAC,QAAQ;AAChC,aAAW,QAAQ,MAAM;AACvB,QAAI,CAAC,gBAAgB,IAAI,GAAG;AAC1B,YAAM,OAAO,OAAO,IAAI,MAAM,iCAAiC,IAAI,EAAE,GAAG,EAAE,UAAU,EAAE,CAAC;AAAA,IACzF;AAAA,EACF;AACA,SAAO;AACT;AAQA,eAAsB,cAAc,MAAuD;AACzF,QAAM,OAAO,KAAK,QAAQ,SAAS,mBAAmB,CAAC;AACvD,QAAM,SAAS,KAAK,UAAU,oBAAoB;AAClD,QAAM,OAAmB,KAAK,QAAQ;AACtC,QAAM,MAAM,KAAK,OAAO,oBAAI,KAAK;AACjC,QAAM,aAAa,kBAAkB,KAAK,EAAE;AAE5C,QAAM,WAAW,MAAM,aAAa,IAAI;AACxC,MAAI;AACJ,MAAI,KAAK,KAAK;AAKZ,cAAU,SAAS,OAAO,CAAC,MAAM,EAAE,WAAW,QAAQ,kBAAkB,IAAI,EAAE,MAAM,CAAC;AAAA,EACvF,WAAW,KAAK,MAAM;AACpB,UAAM,SAAS,SAAS,KAAK,IAAI;AACjC,cAAU,SAAS,OAAO,CAAC,MAAM,SAAS,EAAE,IAAI,MAAM,MAAM;AAAA,EAC9D,OAAO;AACL,UAAM,OAAO,OAAO,IAAI,MAAM,8BAA8B,GAAG,EAAE,UAAU,EAAE,CAAC;AAAA,EAChF;AAEA,QAAM,UAAqC,CAAC;AAC5C,aAAW,KAAK,SAAS;AACvB,QAAI;AACF,YAAM,SAAS,cAAc,CAAC;AAC9B,UAAI,CAAC,QAAQ;AACX,gBAAQ,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,WAAW,QAAQ,4BAA4B,CAAC;AACrF;AAAA,MACF;AACA,UAAI,CAAC,EAAE,aAAa;AAClB,gBAAQ,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,WAAW,QAAQ,kBAAkB,CAAC;AAC3E;AAAA,MACF;AACA,YAAM,WAAW,MAAM,qBAAqB,EAAE,YAAY,GAAG;AAC7D,YAAM,SAAS,MAAM,mBAAmB,SAAS,KAAK;AACtD,YAAM,OAAO,SAAS,EAAE,IAAI;AAC5B,YAAM,aAAa,MAAM,uBAAuB,GAAG,MAAM,KAAK,EAAE,QAAQ,OAAO,CAAC;AAChF,YAAM,EAAE,MAAM,aAAa,QAAQ,IAAI,MAAM,kBAAkB,YAAY;AAAA,QACzE;AAAA,QACA,SAAS,GAAG,IAAI;AAAA,MAClB,CAAC;AAED,UAAI,KAAK,QAAQ;AACf,cAAM,OAAO,WAAW,IAAI,aAAa,KAAK,YAAY,CAAC;AAC3D,cAAM,MAAM,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;AAC9C,cAAM,UAAU,MAAM,MAAM,OAAO;AACnC,gBAAQ,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,WAAW,SAAS,WAAW,CAAC;AACrE;AAAA,MACF;AAEA,YAAM,OAAO,KAAK;AAAA,QAChB,MAAM;AAAA,QACN,IAAI;AAAA,QACJ,SAAS;AAAA,QACT;AAAA,QACA;AAAA,QACA;AAAA,MACF,CAAC;AACD,cAAQ,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,QAAQ,SAAS,WAAW,CAAC;AAAA,IACpE,SAAS,KAAK;AACZ,cAAQ,KAAK;AAAA,QACX,MAAM,EAAE;AAAA,QACR,QAAQ;AAAA,QACR,SAAS,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,MAC1D,CAAC;AAAA,IACH;AAAA,EACF;AAEA,SAAO,EAAE,QAAQ;AACnB;;;AC1HA,IAAM,QAAoC;AAAA,EACxC,cAAc;AAAA,EACd,aAAa;AAAA,EACb,SAAS;AAAA,EACT,QAAQ;AACV;AAEA,SAAS,aAAa,GAAoC;AACxD,MAAI,EAAE,WAAW,UAAW,QAAO,IAAI,EAAE,IAAI,oBAAe,EAAE,MAAM;AACpE,MAAI,EAAE,WAAW,QAAS,QAAO,IAAI,EAAE,IAAI,YAAY,EAAE,OAAO;AAChE,SAAO,IAAI,EAAE,IAAI,KAAK,EAAE,MAAM,YAAO,EAAE,OAAO,YAAO,EAAE,WAAW,KAAK,IAAI,CAAC;AAC9E;AAOA,eAAsB,mBACpB,MACA,MACA,MAC2C;AAC3C,MAAI,SAAS,SAAS;AACpB,WAAO,EAAE,QAAQ,0BAA0B,IAAI,uBAAuB,MAAM,EAAE;AAAA,EAChF;AACA,MAAI,QAAQ,IAAI,MAAM,QAAQ,KAAK,GAAG,GAAG;AACvC,WAAO,EAAE,QAAQ,2CAA2C,MAAM,EAAE;AAAA,EACtE;AACA,QAAM,WAAW,KAAK,QAAQ,gBAAgB,YAAY;AAC1D,QAAM,OAAO,MAAM,OAAO;AAC1B,MAAI,CAAC,MAAM;AACT,WAAO;AAAA,MACL,QAAQ,mBAAmB,KAAK,IAAI,iBAAiB,OAAO,KAAK,KAAK,EAAE,KAAK,IAAI,CAAC;AAAA,MAClF,MAAM;AAAA,IACR;AAAA,EACF;AAEA,MAAI;AACF,UAAM,EAAE,QAAQ,IAAI,MAAM,cAAc;AAAA,MACtC,GAAI,OAAO,EAAE,KAAK,IAAI,CAAC;AAAA,MACvB,GAAI,KAAK,MAAM,EAAE,KAAK,KAAK,IAAI,CAAC;AAAA,MAChC;AAAA,MACA,GAAI,KAAK,KAAK,EAAE,IAAI,KAAK,GAAG,IAAI,CAAC;AAAA,MACjC,GAAI,KAAK,SAAS,EAAE,QAAQ,KAAK,IAAI,CAAC;AAAA,IACxC,CAAC;AACD,UAAM,SACJ,QAAQ,WAAW,IAAI,uBAAuB,QAAQ,IAAI,YAAY,EAAE,KAAK,IAAI;AACnF,UAAM,OAAO,QAAQ,KAAK,CAAC,MAAM,EAAE,WAAW,OAAO,IAAI,IAAI;AAC7D,WAAO,EAAE,QAAQ,KAAK;AAAA,EACxB,SAAS,KAAK;AACZ,UAAM,IAAI;AACV,WAAO,EAAE,QAAQ,EAAE,WAAW,OAAO,GAAG,GAAG,MAAM,EAAE,YAAY,EAAE;AAAA,EACnE;AACF;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/recipes/selftest-email.ts","../src/cli/commands/selftest.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport { listWebsites, siteSlug } from \"../reports/airtable/websites.js\";\nimport { ELIGIBLE_STATUSES } from \"../reports/due.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { fetchAttachmentBytes } from \"../reports/airtable/attachments.js\";\nimport { prepareHeaderImage } from \"../reports/maintenance-email/header-image.js\";\nimport { buildReportDataForSite, scoresFromRow } from \"../reports/report-data.js\";\nimport { renderReportEmail } from \"../reports/send/render-email.js\";\nimport { defaultResendClient, type ResendClient } from \"../reports/send/resend.js\";\nimport { parseAddresses, isProbablyEmail } from \"../reports/send/orchestrate.js\";\nimport type { ReportType } from \"../reports/types.js\";\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\nconst REPLY_TO = \"info@reddoorla.com\";\n\nexport type SelftestEmailDeps = {\n /** Airtable handle (read-only here). Defaults to the live base from credentials. */\n base?: AirtableBase;\n /** Resend client. Defaults to the real client. */\n resend?: ResendClient;\n /** Single-site slug. Mutually exclusive with `all`. */\n site?: string;\n /** All `maintenance` sites (one email each). Mutually exclusive with `site`. */\n all?: boolean;\n /** Report type to preview. Default \"Announcement\". */\n type?: ReportType;\n /** Raw `--to` (comma- or newline-separated). Default: OPERATOR_EMAIL → info@reddoorla.com. */\n to?: string;\n /** Render only; write reports/<slug>/selftest-<type>.html, never send. */\n dryRun?: boolean;\n /** Single timestamp driving the window + completedOn. */\n now?: Date;\n};\n\nexport type SelftestEmailSiteResult =\n | { site: string; status: \"sent\" | \"dry-run\"; subject: string; recipients: string[] }\n | { site: string; status: \"skipped\"; reason: string }\n | { site: string; status: \"error\"; message: string };\n\nexport type SelftestEmailResult = { results: SelftestEmailSiteResult[] };\n\n/** Resolve the recipient list: explicit `--to` (validated) else the operator default. */\nfunction resolveRecipients(to: string | undefined): string[] {\n const operator = process.env.OPERATOR_EMAIL?.trim() || \"info@reddoorla.com\";\n const parsed = to ? parseAddresses(to) : null;\n const list = parsed ?? [operator];\n for (const addr of list) {\n if (!isProbablyEmail(addr)) {\n throw Object.assign(new Error(`--to has a malformed address: ${addr}`), { exitCode: 2 });\n }\n }\n return list;\n}\n\n/**\n * Send (or dry-render) a single report email per target site to the operator/`--to`, with NO\n * Airtable side effects (no draft, queue, or stamp). Mirrors the production render+send via the\n * shared `renderReportEmail` seam, so the preview matches a real send. One bad site never aborts\n * `--all` (per-site try/catch). Sites missing stored scores or a header image are skipped.\n */\nexport async function selftestEmail(deps: SelftestEmailDeps): Promise<SelftestEmailResult> {\n const base = deps.base ?? openBase(readAirtableConfig());\n const resend = deps.resend ?? defaultResendClient();\n const type: ReportType = deps.type ?? \"Announcement\";\n const now = deps.now ?? new Date();\n const recipients = resolveRecipients(deps.to);\n\n const websites = await listWebsites(base);\n let targets: WebsiteRow[];\n if (deps.all) {\n // The report-eligible set (maintenance + hosting), not a hard-coded \"maintenance\" —\n // the latter silently excluded hosting sites and implied a type↔status coupling that\n // doesn't exist (the requested --type drives the rendered template; single-site mode\n // applies no status filter at all).\n targets = websites.filter((w) => w.status !== null && ELIGIBLE_STATUSES.has(w.status));\n } else if (deps.site) {\n const wanted = siteSlug(deps.site);\n targets = websites.filter((w) => siteSlug(w.name) === wanted);\n } else {\n throw Object.assign(new Error(\"Provide a site slug or --all\"), { exitCode: 2 });\n }\n\n const results: SelftestEmailSiteResult[] = [];\n for (const w of targets) {\n try {\n const scores = scoresFromRow(w);\n if (!scores) {\n results.push({ site: w.name, status: \"skipped\", reason: \"missing Lighthouse scores\" });\n continue;\n }\n if (!w.headerImage) {\n results.push({ site: w.name, status: \"skipped\", reason: \"no Header image\" });\n continue;\n }\n const original = await fetchAttachmentBytes(w.headerImage.url);\n const header = await prepareHeaderImage(original.bytes);\n const slug = siteSlug(w.name);\n const reportData = await buildReportDataForSite(w, type, now, { scores, header });\n const { html, attachments, subject } = await renderReportEmail(reportData, {\n header,\n cidName: `${slug}-header`,\n });\n\n if (deps.dryRun) {\n const path = `reports/${slug}/selftest-${type.toLowerCase()}.html`;\n await mkdir(dirname(path), { recursive: true });\n await writeFile(path, html, \"utf-8\");\n results.push({ site: w.name, status: \"dry-run\", subject, recipients });\n continue;\n }\n\n await resend.send({\n from: FROM_ADDRESS,\n to: recipients,\n replyTo: REPLY_TO,\n subject,\n html,\n attachments,\n });\n results.push({ site: w.name, status: \"sent\", subject, recipients });\n } catch (err) {\n results.push({\n site: w.name,\n status: \"error\",\n message: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n return { results };\n}\n","import { selftestEmail, type SelftestEmailSiteResult } from \"../../recipes/selftest-email.js\";\nimport type { ReportType } from \"../../reports/types.js\";\n\nexport type SelftestCommandOptions = {\n type?: string;\n to?: string;\n all?: boolean;\n dryRun?: boolean;\n cwd?: string;\n};\n\nconst TYPES: Record<string, ReportType> = {\n announcement: \"Announcement\",\n maintenance: \"Maintenance\",\n testing: \"Testing\",\n launch: \"Launch\",\n};\n\nfunction formatResult(r: SelftestEmailSiteResult): string {\n if (r.status === \"skipped\") return `[${r.site}] skipped — ${r.reason}`;\n if (r.status === \"error\") return `[${r.site}] error: ${r.message}`;\n return `[${r.site}] ${r.status} — \"${r.subject}\" → ${r.recipients.join(\", \")}`;\n}\n\n/**\n * `selftest <kind> [site]` — operator self-tests. The only kind today is `email`: preview a\n * report email for one site (or `--all` maintenance sites) to the operator/`--to`, with no\n * Airtable side effects. Validates kind/type and the site-xor-all rule before doing any work.\n */\nexport async function runSelftestCommand(\n kind: string,\n site: string | undefined,\n opts: SelftestCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (kind !== \"email\") {\n return { output: `Unknown selftest kind '${kind}'. Supported: email`, code: 2 };\n }\n if (Boolean(site) === Boolean(opts.all)) {\n return { output: \"Provide exactly one of <site> or --all.\", code: 2 };\n }\n const typeKey = (opts.type ?? \"announcement\").toLowerCase();\n const type = TYPES[typeKey];\n if (!type) {\n return {\n output: `Unknown --type '${opts.type}'. Supported: ${Object.keys(TYPES).join(\", \")}`,\n code: 2,\n };\n }\n\n try {\n const { results } = await selftestEmail({\n ...(site ? { site } : {}),\n ...(opts.all ? { all: true } : {}),\n type,\n ...(opts.to ? { to: opts.to } : {}),\n ...(opts.dryRun ? { dryRun: true } : {}),\n });\n const output =\n results.length === 0 ? \"No matching sites.\" : results.map(formatResult).join(\"\\n\");\n const code = results.some((r) => r.status === \"error\") ? 1 : 0;\n return { output, code };\n } catch (err) {\n const e = err as { message?: string; exitCode?: number };\n return { output: e.message ?? String(err), code: e.exitCode ?? 1 };\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -11,9 +11,9 @@ import {
|
|
|
11
11
|
} from "./chunk-4BHNP6Y2.js";
|
|
12
12
|
import {
|
|
13
13
|
smokeSuite
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-K5JZ5KLO.js";
|
|
15
15
|
import "./chunk-KRDLHAKK.js";
|
|
16
|
-
import "./chunk-
|
|
16
|
+
import "./chunk-ZTPPCHDB.js";
|
|
17
17
|
import "./chunk-FDIFTIXC.js";
|
|
18
18
|
import "./chunk-HVOOCK6L.js";
|
|
19
19
|
import "./chunk-XXTZBPUY.js";
|
|
@@ -51,4 +51,4 @@ async function runSmokeSuiteCommand(site, opts) {
|
|
|
51
51
|
export {
|
|
52
52
|
runSmokeSuiteCommand
|
|
53
53
|
};
|
|
54
|
-
//# sourceMappingURL=smoke-suite-
|
|
54
|
+
//# sourceMappingURL=smoke-suite-SUEJFBYP.js.map
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
toFormType,
|
|
3
3
|
toNotifyStatus,
|
|
4
4
|
toStatus
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-2SLED3NB.js";
|
|
6
6
|
import {
|
|
7
7
|
SIGNUP_FORM_TYPES
|
|
8
8
|
} from "./chunk-2OP4JKIQ.js";
|
|
@@ -27,7 +27,8 @@ function rowFromDb(r) {
|
|
|
27
27
|
notifyStatus: toNotifyStatus(r.notify_status),
|
|
28
28
|
resendMessageId: r.resend_message_id,
|
|
29
29
|
spamScore: typeof r.spam_score === "number" ? r.spam_score : null,
|
|
30
|
-
spamReason: r.spam_reason
|
|
30
|
+
spamReason: r.spam_reason,
|
|
31
|
+
fanoutStatus: r.fanout_status
|
|
31
32
|
};
|
|
32
33
|
}
|
|
33
34
|
function newSubmissionId() {
|
|
@@ -59,7 +60,9 @@ async function createSubmission(db, input) {
|
|
|
59
60
|
notify_status: "skipped",
|
|
60
61
|
resend_message_id: null,
|
|
61
62
|
spam_score: input.spamScore ?? null,
|
|
62
|
-
spam_reason: input.spamReason ?? null
|
|
63
|
+
spam_reason: input.spamReason ?? null,
|
|
64
|
+
// Always null at insert: the fan-out runs after the row exists (stampFanout).
|
|
65
|
+
fanout_status: null
|
|
63
66
|
}).execute();
|
|
64
67
|
const created = await getSubmissionById(db, id);
|
|
65
68
|
if (!created) throw new Error("createSubmission: row vanished after insert");
|
|
@@ -80,6 +83,9 @@ async function stampNotified(db, id, status, messageId) {
|
|
|
80
83
|
const patch = messageId !== null ? { notify_status: status, resend_message_id: messageId } : { notify_status: status };
|
|
81
84
|
await db.updateTable("submissions").set(patch).where("id", "=", id).execute();
|
|
82
85
|
}
|
|
86
|
+
async function stampFanout(db, id, fanoutStatus) {
|
|
87
|
+
await db.updateTable("submissions").set({ fanout_status: fanoutStatus }).where("id", "=", id).execute();
|
|
88
|
+
}
|
|
83
89
|
function applySubmissionFilter(qb, f) {
|
|
84
90
|
let q = qb;
|
|
85
91
|
if (f.siteId !== void 0) q = q.where("site_id", "=", f.siteId);
|
|
@@ -227,7 +233,8 @@ async function backfillSubmission(db, row) {
|
|
|
227
233
|
notify_status: row.notifyStatus,
|
|
228
234
|
resend_message_id: row.resendMessageId,
|
|
229
235
|
spam_score: row.spamScore ?? null,
|
|
230
|
-
spam_reason: row.spamReason ?? null
|
|
236
|
+
spam_reason: row.spamReason ?? null,
|
|
237
|
+
fanout_status: row.fanoutStatus ?? null
|
|
231
238
|
}).onConflict((oc) => oc.column("id").doNothing()).execute();
|
|
232
239
|
}
|
|
233
240
|
async function rescoreSubmissionSpam(db, id, score, reason) {
|
|
@@ -276,6 +283,7 @@ export {
|
|
|
276
283
|
newSubmissionId,
|
|
277
284
|
rescoreSubmissionSpam,
|
|
278
285
|
setSubmissionStatusRow,
|
|
286
|
+
stampFanout,
|
|
279
287
|
stampNotified
|
|
280
288
|
};
|
|
281
|
-
//# sourceMappingURL=submissions-
|
|
289
|
+
//# sourceMappingURL=submissions-3I3GAQAX.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/db/submissions.ts"],"sourcesContent":["import { sql } from \"kysely\";\nimport type { Selectable, SqlBool } from \"kysely\";\nimport type { Db } from \"./client.js\";\nimport type { SubmissionsTable } from \"./schema.js\";\nimport {\n type SubmissionRow,\n type SubmissionInput,\n toFormType,\n toStatus,\n toNotifyStatus,\n} from \"../reports/submission-row.js\";\nimport type { FormType, SubmissionStatus } from \"../reports/submission-row.js\";\nimport { SIGNUP_FORM_TYPES } from \"../forms/types.js\";\n\nexport type SubmissionFilter = {\n siteId?: string;\n formType?: FormType;\n status?: SubmissionStatus;\n /** LIKE %q% across name/email/message/phone, case-insensitive */\n search?: string;\n /** submitted_at >= from (ISO string) */\n from?: string;\n /** submitted_at <= to (ISO string) */\n to?: string;\n /** exact spam_reason token (comma-boundary match; a stored \":N\" count suffix on\n * the token still matches) */\n reason?: string;\n};\n\n/** Map a raw DB row to the canonical SubmissionRow, narrowing the enum columns\n * with the SAME validators the Airtable mapRow uses — SQLite stores TEXT, so a\n * bad stored value must still be defended against. */\nfunction rowFromDb(r: Selectable<SubmissionsTable>): SubmissionRow {\n return {\n id: r.id,\n submissionId: r.submission_id,\n siteId: r.site_id,\n formType: toFormType(r.form_type),\n name: r.name,\n email: r.email,\n phone: r.phone,\n message: r.message,\n extraFields: r.extra_fields,\n sourceUrl: r.source_url,\n utm: r.utm,\n submittedAt: r.submitted_at,\n status: toStatus(r.status),\n notifyStatus: toNotifyStatus(r.notify_status),\n resendMessageId: r.resend_message_id,\n spamScore: typeof r.spam_score === \"number\" ? r.spam_score : null,\n spamReason: r.spam_reason,\n fanoutStatus: r.fanout_status,\n };\n}\n\n/** Opaque, collision-free id. crypto is a Node 20 global — no new dep. */\nexport function newSubmissionId(): string {\n return `sub_${crypto.randomUUID()}`;\n}\n\nexport async function getSubmissionById(db: Db, id: string): Promise<SubmissionRow | null> {\n const r = await db.selectFrom(\"submissions\").selectAll().where(\"id\", \"=\", id).executeTakeFirst();\n return r ? rowFromDb(r) : null;\n}\n\nexport async function createSubmission(db: Db, input: SubmissionInput): Promise<SubmissionRow> {\n const id = newSubmissionId();\n const extra =\n input.extraFields !== undefined && Object.keys(input.extraFields).length > 0\n ? JSON.stringify(input.extraFields)\n : null;\n await db\n .insertInto(\"submissions\")\n .values({\n id,\n // Display number: MAX+1 in a single statement. libSQL is single-writer, so\n // writes serialize and this is race-free.\n submission_id: sql<number>`(SELECT COALESCE(MAX(submission_id), 0) + 1 FROM submissions)`,\n site_id: input.siteId,\n form_type: input.formType,\n name: input.name,\n email: input.email,\n phone: input.phone ?? null,\n message: input.message ?? null,\n extra_fields: extra,\n source_url: input.sourceUrl ?? null,\n utm: input.utm ?? null,\n submitted_at: input.submittedAt.toISOString(),\n status: input.status ?? \"new\",\n notify_status: \"skipped\",\n resend_message_id: null,\n spam_score: input.spamScore ?? null,\n spam_reason: input.spamReason ?? null,\n // Always null at insert: the fan-out runs after the row exists (stampFanout).\n fanout_status: null,\n })\n .execute();\n const created = await getSubmissionById(db, id);\n if (!created) throw new Error(\"createSubmission: row vanished after insert\");\n return created;\n}\n\nimport type { NotifyStatus } from \"../reports/submission-row.js\";\n\nexport async function listNewSubmissions(db: Db, max = 200): Promise<SubmissionRow[]> {\n // Bound the fetch (matches listSubmissionsForSite's default): the cockpit loads this whole\n // array on every render. Unbounded, it deserializes every unread submission fleet-wide. The\n // badge count therefore caps at `max` — acceptable, since >200 unread fleet-wide is itself a\n // triage emergency, and the /submissions page is the unbounded, filterable view.\n const rows = await db\n .selectFrom(\"submissions\")\n .selectAll()\n .where(\"status\", \"=\", \"new\")\n .orderBy(\"submitted_at\", \"desc\")\n .limit(max)\n .execute();\n return rows.map(rowFromDb);\n}\n\n/** Same signature shape as the Airtable version (takes `{ id, name }`) so the\n * composition-root swap is import-only — but here we filter by id directly, with\n * no linked-field/primary-field workaround and no JS-confirm pass. */\nexport async function listSubmissionsForSite(\n db: Db,\n site: { id: string; name: string },\n max = 200,\n): Promise<SubmissionRow[]> {\n const rows = await db\n .selectFrom(\"submissions\")\n .selectAll()\n .where(\"site_id\", \"=\", site.id)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(max)\n .execute();\n return rows.map(rowFromDb);\n}\n\nexport async function setSubmissionStatusRow(\n db: Db,\n id: string,\n status: SubmissionStatus,\n): Promise<void> {\n await db.updateTable(\"submissions\").set({ status }).where(\"id\", \"=\", id).execute();\n}\n\nexport async function stampNotified(\n db: Db,\n id: string,\n status: NotifyStatus,\n messageId: string | null,\n): Promise<void> {\n const patch =\n messageId !== null\n ? { notify_status: status, resend_message_id: messageId }\n : { notify_status: status };\n await db.updateTable(\"submissions\").set(patch).where(\"id\", \"=\", id).execute();\n}\n\n/** Record the newsletter fan-out outcome on a row (comma-joined destination tokens,\n * see ingest.ts). REPLACES any previous value — the fan-out runs once per submission,\n * so the last write is the whole truth; unlike markSubmissionsSpamRetro there is no\n * earlier trail worth appending to. No status guard: this is provenance about what\n * the pipeline did, not a triage decision, so it stays accurate even for a row the\n * operator has already read or archived. */\nexport async function stampFanout(db: Db, id: string, fanoutStatus: string): Promise<void> {\n await db\n .updateTable(\"submissions\")\n .set({ fanout_status: fanoutStatus })\n .where(\"id\", \"=\", id)\n .execute();\n}\n\n// NOTE: listSubmissionsFiltered and countSubmissionsFiltered share identical filter\n// logic via applySubmissionFilter — always update both through that helper.\nfunction applySubmissionFilter<O>(\n qb: import(\"kysely\").SelectQueryBuilder<import(\"./schema.js\").Database, \"submissions\", O>,\n f: SubmissionFilter,\n) {\n let q = qb;\n if (f.siteId !== undefined) q = q.where(\"site_id\", \"=\", f.siteId);\n if (f.formType !== undefined) q = q.where(\"form_type\", \"=\", f.formType);\n if (f.status !== undefined) q = q.where(\"status\", \"=\", f.status);\n if (f.from !== undefined) q = q.where(\"submitted_at\", \">=\", f.from);\n if (f.to !== undefined) q = q.where(\"submitted_at\", \"<=\", f.to);\n if (f.search !== undefined && f.search.trim() !== \"\") {\n // Escape LIKE metacharacters so a user's literal `%`/`_` (or the escape char `\\`) match\n // literally rather than as wildcards: searching `john_doe` must not also hit `johnXdoe`,\n // and a bare `%` must not match everything. `\\` is escaped first (it's the ESCAPE char).\n // Parameterized regardless (no injection) — this is a correctness fix. Uses sql`` because\n // kysely's `like` operator can't attach an ESCAPE clause.\n const term = f.search\n .trim()\n .toLowerCase()\n .replace(/[\\\\%_]/g, \"\\\\$&\");\n const like = `%${term}%`;\n q = q.where((eb) =>\n eb.or([\n sql<SqlBool>`lower(${eb.ref(\"name\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"email\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"message\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"phone\")}) like ${like} escape '\\\\'`,\n ]),\n );\n }\n if (f.reason !== undefined && f.reason.trim() !== \"\") {\n // spam_reason is a comma-joined token list (ingest.ts joins \",\"; retro appends\n // \",<reason>\"). Comma-boundary match so \"links\" never matches \"backlinks\" and\n // \"turnstile-required-absent\" never matches \"-failed\"; the second branch accepts\n // a per-token count suffix (\"keywords:4\" matches reason=keywords). LIKE\n // metacharacters escaped exactly like the search branch. lower() both sides is\n // safe: tokens are ASCII classifier codes.\n const token = f.reason\n .trim()\n .toLowerCase()\n .replace(/[\\\\%_]/g, \"\\\\$&\");\n q = q.where((eb) =>\n eb.or([\n sql<SqlBool>`',' || lower(${eb.ref(\"spam_reason\")}) || ',' like ${`%,${token},%`} escape '\\\\'`,\n sql<SqlBool>`',' || lower(${eb.ref(\"spam_reason\")}) || ',' like ${`%,${token}:%`} escape '\\\\'`,\n ]),\n );\n }\n return q;\n}\n\nexport async function listSubmissionsFiltered(\n db: Db,\n filter: SubmissionFilter,\n opts: { limit: number; offset: number },\n): Promise<SubmissionRow[]> {\n const base = db.selectFrom(\"submissions\").selectAll();\n const rows = await applySubmissionFilter(base, filter)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(opts.limit)\n .offset(opts.offset)\n .execute();\n return rows.map(rowFromDb);\n}\n\nexport async function countSubmissionsFiltered(db: Db, filter: SubmissionFilter): Promise<number> {\n const base = db.selectFrom(\"submissions\").select((eb) => eb.fn.countAll<number>().as(\"n\"));\n const res = await applySubmissionFilter(base, filter).executeTakeFirstOrThrow();\n return Number(res.n);\n}\n\n/** Fleet-wide count of auto-filtered spam (`status = 'spam_auto'`) submitted on/after\n * `sinceDate` (ISO). Powers the cockpit \"N auto-filtered this week — review\" affordance;\n * the caller picks the window (like `listScreenOutsSince`), keeping this query pure. */\nexport async function countAutoSpamSince(db: Db, sinceDate: string): Promise<number> {\n const res = await db\n .selectFrom(\"submissions\")\n .select((eb) => eb.fn.countAll<number>().as(\"n\"))\n .where(\"status\", \"=\", \"spam_auto\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .executeTakeFirstOrThrow();\n return Number(res.n);\n}\n\nexport type SiteSubmissionCounts = { leads: number; signups: number; spamAuto: number };\n\n/** Per-site submission counts on/after `sinceIso` (full ISO timestamp) for the\n * digest telemetry line. Grouped one-query: leads/signups = rows NOT auto- or\n * operator-bucketed as spam (status NOT IN ('spam_auto','spam')) split by\n * isLeadFormType; spamAuto = status='spam_auto'. Keyed by Websites record id. */\nexport async function countSubmissionsSinceBySite(\n db: Db,\n sinceIso: string,\n): Promise<Map<string, SiteSubmissionCounts>> {\n const signupList = sql.join(SIGNUP_FORM_TYPES.map((t) => sql`${t}`));\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\n \"site_id\",\n sql<number>`sum(case when status not in ('spam_auto','spam') and form_type not in (${signupList}) then 1 else 0 end)`.as(\n \"leads_n\",\n ),\n sql<number>`sum(case when status not in ('spam_auto','spam') and form_type in (${signupList}) then 1 else 0 end)`.as(\n \"signups_n\",\n ),\n sql<number>`sum(case when status = 'spam_auto' then 1 else 0 end)`.as(\"spam_auto_n\"),\n ])\n .where(\"submitted_at\", \">=\", sinceIso)\n .groupBy(\"site_id\")\n .execute();\n return new Map(\n rows.map((r) => [\n r.site_id,\n {\n leads: Number(r.leads_n) || 0,\n signups: Number(r.signups_n) || 0,\n spamAuto: Number(r.spam_auto_n) || 0,\n },\n ]),\n );\n}\n\n/** A normalized body shorter than this never triggers the exact-duplicate signal —\n * short/boilerplate lines (a newsletter \"subscribe\", \"hi\") legitimately repeat\n * across real people. */\nexport const MIN_DUP_BODY_LEN = 40;\n\n/** Both token sets must be at least this large before the Jaccard tier can fire —\n * two short genuine messages (\"what are your hours?\" variants) share most of their\n * few tokens and would otherwise collide. */\nexport const MIN_SIMILAR_TOKENS = 25;\n\n/** Jaccard similarity (token sets) at or above this marks a near-duplicate. 0.9 is\n * deliberately strict: a template spray with one substituted greeting/target still\n * clears it, while two independently-written enquiries about the same business\n * never do. */\nexport const SIMILARITY_THRESHOLD = 0.9;\n\n/** Fold a message body down to its template skeleton. Full-Unicode lowercase first\n * (JS `toLowerCase()` — SQLite's `lower()` is ASCII-only, which is why this whole\n * comparison lives in JS, folding BOTH sides identically so byte-identical Cyrillic\n * copies keep matching), then strip everything a spray substitutes per target —\n * URLs, emails, bare domains, digit runs — and collapse whitespace. Over-stripping\n * is safe: it happens symmetrically on both sides. */\nfunction normalizeBody(body: string): string {\n return body\n .toLowerCase()\n .replace(/https?:\\/\\/\\S+/gu, \" \") // full URLs\n .replace(/www\\.\\S+/gu, \" \") // scheme-less www. links\n .replace(/\\S+@\\S+/gu, \" \") // email addresses\n .replace(/\\b[a-z0-9-]+(\\.[a-z0-9-]+)+\\b/gu, \" \") // bare domain tokens (word.tld)\n .replace(/\\d+/gu, \" \") // digit runs (phone numbers, prices, years)\n .replace(/\\s+/gu, \" \")\n .trim();\n}\n\n/** Letter-only token set of a normalized body (punctuation/symbols are separators). */\nfunction tokenSet(normalized: string): Set<string> {\n return new Set(normalized.split(/[^\\p{L}]+/u).filter((t) => t.length > 0));\n}\n\nfunction jaccard(a: Set<string>, b: Set<string>): number {\n let intersection = 0;\n for (const t of a) if (b.has(t)) intersection++;\n const union = a.size + b.size - intersection;\n return union === 0 ? 0 : intersection / union;\n}\n\n/** Fleet-wide duplicate/near-duplicate lookup for the spray spam signal — the same\n * pitch blasted across sites shows up as identical OR near-identical bodies (the\n * live dog-harness spray differed ONLY in greeting; SEO sprays substitute the\n * target domain per site — exact match alone missed both). Scans rows submitted\n * on/after `sinceDate` and compares in JS (see normalizeBody for why not SQL):\n * - `exact`: normalized equality, only when the normalized incoming body is at\n * least MIN_DUP_BODY_LEN chars.\n * - `similar`: token-set Jaccard >= SIMILARITY_THRESHOLD, only when BOTH sets have\n * at least MIN_SIMILAR_TOKENS tokens. A row matched as exact never re-appears here.\n * Returned statuses let the caller retro-bucket still-'new' prior copies. The 2000-row\n * LIMIT is a safety bound only — current volume is ~130/month, so a 30-day window is\n * always a full scan. */\n/** One matched prior row from the duplicate scan. Carries siteId + email so the\n * ingest caller can exempt a GENUINE same-sender resubmission on the same site\n * (a real visitor double-submitting / resending after silence) — only cross-site\n * or different-sender copies are spray evidence. */\nexport type DuplicateMatch = { id: string; status: string; siteId: string; email: string };\n\nexport async function findRecentDuplicateSubmissions(\n db: Db,\n message: string,\n sinceDate: string,\n): Promise<{\n exact: DuplicateMatch[];\n similar: DuplicateMatch[];\n}> {\n const incoming = normalizeBody(message);\n const incomingTokens = tokenSet(incoming);\n const exactEligible = incoming.length >= MIN_DUP_BODY_LEN;\n const similarEligible = incomingTokens.size >= MIN_SIMILAR_TOKENS;\n // Too short for either tier — skip the scan entirely (nothing could match).\n if (!exactEligible && !similarEligible) return { exact: [], similar: [] };\n\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\"id\", \"status\", \"message\", \"site_id\", \"email\"])\n .where(\"message\", \"is not\", null)\n .where(\"submitted_at\", \">=\", sinceDate)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(2000)\n .execute();\n\n const exact: DuplicateMatch[] = [];\n const similar: DuplicateMatch[] = [];\n for (const row of rows) {\n const stored = normalizeBody(row.message ?? \"\");\n const match: DuplicateMatch = {\n id: row.id,\n status: row.status,\n siteId: row.site_id,\n email: row.email,\n };\n if (exactEligible && stored === incoming) {\n exact.push(match);\n continue; // an exact match must not also appear in similar\n }\n if (!similarEligible) continue;\n const storedTokens = tokenSet(stored);\n if (storedTokens.size < MIN_SIMILAR_TOKENS) continue;\n if (jaccard(incomingTokens, storedTokens) >= SIMILARITY_THRESHOLD) {\n similar.push(match);\n }\n }\n return { exact, similar };\n}\n\n/** Recent non-newsletter submissions from `email` (case/whitespace-folded), on/after\n * `sinceDate` (ISO). Powers the cross-site repeat-sender signal: the fleet's sites\n * are UNRELATED businesses, so the same address writing to 2+ of them inside a month\n * is a solicitation tell — the caller compares siteIds (same-site repeats are genuine\n * follow-ups). Newsletter rows are excluded: one person subscribing on two sites is\n * legitimate. Both sides fold in SQL (same-side folding, see the normalizeBody note). */\nexport async function listRecentSubmissionsForEmail(\n db: Db,\n email: string,\n sinceDate: string,\n): Promise<Array<{ id: string; siteId: string; status: string }>> {\n if (email.trim() === \"\") return [];\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\"id\", \"site_id\", \"status\"])\n .where(\"form_type\", \"!=\", \"newsletter\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .where((eb) => sql<SqlBool>`lower(trim(${eb.ref(\"email\")})) = lower(trim(${email}))`)\n .execute();\n return rows.map((r) => ({ id: r.id, siteId: r.site_id, status: r.status }));\n}\n\n/** Retroactively re-bucket prior spray copies once a later copy identifies the spray\n * (the FIRST copy is always delivered by design — only a repeat reveals it). Appends\n * `retroReason` to any existing spam_reason so the original classifier trail survives.\n * The `status = 'new'` guard is load-bearing: rows the operator already read/replied/\n * archived/marked are NEVER touched — this only cleans the unread queue. */\nexport async function markSubmissionsSpamRetro(\n db: Db,\n ids: string[],\n retroReason: string,\n): Promise<void> {\n if (ids.length === 0) return;\n await db\n .updateTable(\"submissions\")\n .set({\n status: \"spam_auto\",\n spam_reason: sql<string>`CASE WHEN spam_reason IS NULL OR spam_reason = '' THEN ${retroReason} ELSE spam_reason || ',' || ${retroReason} END`,\n })\n .where(\"id\", \"in\", ids)\n .where(\"status\", \"=\", \"new\")\n .execute();\n}\n\n/** spam_reason strings for EVERY row matching `filter` (not just one page). Powers the\n * /submissions per-reason facet line, which must tally the WHOLE filtered bucket — the\n * rollout runbook directs the operator to judge the canary from it, and a page-scoped\n * tally under the full-bucket total silently misread once the bucket passed one page.\n * Capped (default 2000) as a safety bound far above any real bucket. */\nexport async function listSpamReasonsFiltered(\n db: Db,\n filter: SubmissionFilter,\n cap = 2000,\n): Promise<string[]> {\n const base = db.selectFrom(\"submissions\").select([\"spam_reason\"]);\n const rows = await applySubmissionFilter(base, filter)\n .where(\"spam_reason\", \"is not\", null)\n .limit(cap)\n .execute();\n return rows.map((r) => r.spam_reason).filter((r): r is string => r !== null && r !== \"\");\n}\n\n/** Insert a SubmissionRow verbatim, preserving its id, display number, and status.\n * ON CONFLICT(id) DO NOTHING makes the whole backfill re-runnable. */\nexport async function backfillSubmission(db: Db, row: SubmissionRow): Promise<void> {\n await db\n .insertInto(\"submissions\")\n .values({\n id: row.id,\n submission_id: row.submissionId,\n site_id: row.siteId,\n form_type: row.formType,\n name: row.name,\n email: row.email,\n phone: row.phone,\n message: row.message,\n extra_fields: row.extraFields,\n source_url: row.sourceUrl,\n utm: row.utm,\n submitted_at: row.submittedAt,\n status: row.status,\n notify_status: row.notifyStatus,\n resend_message_id: row.resendMessageId,\n spam_score: row.spamScore ?? null,\n spam_reason: row.spamReason ?? null,\n fanout_status: row.fanoutStatus ?? null,\n })\n .onConflict((oc) => oc.column(\"id\").doNothing())\n .execute();\n}\n\n/** Re-score write for the `submissions rescore` CLI (2026-07-16): flip ONE still-'new'\n * row to spam_auto with the CURRENT classifier's verdict. Unlike markSubmissionsSpamRetro\n * (which appends to the old trail), this REPLACES spam_score/spam_reason — the point of a\n * re-score is that today's verdict supersedes the stale ingest-time one; the caller bakes\n * its provenance marker (retro-rescore) into `reason`. The status='new' guard is\n * load-bearing: rows the operator already read/replied/archived/marked are NEVER\n * re-bucketed. Returns true when the row was written (false = no longer 'new'). */\nexport async function rescoreSubmissionSpam(\n db: Db,\n id: string,\n score: number,\n reason: string,\n): Promise<boolean> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ status: \"spam_auto\", spam_score: score, spam_reason: reason })\n .where(\"id\", \"=\", id)\n .where(\"status\", \"=\", \"new\")\n .executeTakeFirst();\n return Number(res.numUpdatedRows) > 0;\n}\n\n/** Bulk triage for the /submissions page (2026-07-16): flip EVERY still-'new' row\n * matching `filter` to 'read', server-side, so a 100-row backlog is one click instead\n * of 100. Reuses applySubmissionFilter (via an id-subquery — kysely's update builder\n * can't take the select-only helper directly) so the POST flips exactly the bucket the\n * GET rendered. The outer status='new' guard is load-bearing: spam/archived/read rows\n * are never touched even when the filter matches them (bulk-\"reading\" a spam_auto row\n * would resurrect it into the per-site strip). Returns the number of rows flipped. */\nexport async function markFilteredAsRead(db: Db, filter: SubmissionFilter): Promise<number> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ status: \"read\" })\n .where(\"status\", \"=\", \"new\")\n .where(\"id\", \"in\", (eb) =>\n applySubmissionFilter(eb.selectFrom(\"submissions\").select(\"id\"), filter),\n )\n .executeTakeFirst();\n return Number(res.numUpdatedRows);\n}\n\n/** Flip a submission's notify_status to 'bounced' by its Resend message id — the\n * resend-webhook's mapping from a bounce/complaint event back onto the lead whose\n * notification it was (2026-07-16, the Espada failure mode: 'sent' only means Resend\n * ACCEPTED the email). Returns whether any row matched, so the webhook can tell a\n * submission notification apart from a report email (unknown id → false → the report\n * path handles it). Idempotent: a svix replay re-writes the same terminal value. In\n * practice only 'sent' rows can match — stampNotified stamps the message id and the\n * 'sent' status together, and failed/skipped rows carry no id. */\nexport async function markNotifyBouncedByMessageId(db: Db, messageId: string): Promise<boolean> {\n if (messageId === \"\") return false;\n const res = await db\n .updateTable(\"submissions\")\n .set({ notify_status: \"bounced\" })\n .where(\"resend_message_id\", \"=\", messageId)\n .executeTakeFirst();\n if (Number(res.numUpdatedRows) > 0) return true;\n // A replayed webhook must still report \"this was a submission\" (200, stop svix\n // retrying) even though the terminal value is already written — matched-but-\n // unchanged rows still count in numUpdatedRows on SQLite, but don't rely on it.\n const existing = await db\n .selectFrom(\"submissions\")\n .select(\"id\")\n .where(\"resend_message_id\", \"=\", messageId)\n .executeTakeFirst();\n return existing !== undefined;\n}\n\n/** Per-site counts of bounced lead notifications on/after `sinceDate` (ISO), keyed by\n * the Websites record id (`site_id`). Powers the notify-bounce attention collector —\n * the caller picks the window (like `countAutoSpamSince`), keeping this query pure.\n * Windowed on `submitted_at`: the bounce lands minutes after the submission, and\n * submissions carry no bounce timestamp column (append-only schema, 2026-07-16). */\nexport async function countNotifyBouncedBySite(\n db: Db,\n sinceDate: string,\n): Promise<Map<string, number>> {\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\"site_id\", (eb) => eb.fn.countAll<number>().as(\"n\")])\n .where(\"notify_status\", \"=\", \"bounced\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .groupBy(\"site_id\")\n .execute();\n return new Map(rows.map((r) => [r.site_id, Number(r.n)]));\n}\n"],"mappings":"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","names":[]}
|
|
@@ -10,7 +10,7 @@ function parseExtraFields(raw) {
|
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
async function rescoreNewSubmissions(db, opts) {
|
|
13
|
-
const { listSubmissionsFiltered, rescoreSubmissionSpam } = await import("./submissions-
|
|
13
|
+
const { listSubmissionsFiltered, rescoreSubmissionSpam } = await import("./submissions-3I3GAQAX.js");
|
|
14
14
|
const { classifySpam, SPAM_THRESHOLD } = await import("./spam-classifier-VG4ICIWS.js");
|
|
15
15
|
const rows = [];
|
|
16
16
|
const PAGE = 500;
|
|
@@ -78,7 +78,7 @@ async function runSubmissionsCommand(action, opts) {
|
|
|
78
78
|
return { output: "--apply and --dry-run conflict. Pick one.", code: 2 };
|
|
79
79
|
}
|
|
80
80
|
const apply = opts.apply === true;
|
|
81
|
-
const { openDb, readDbConfig } = await import("./client-
|
|
81
|
+
const { openDb, readDbConfig } = await import("./client-X5WUJWDE.js");
|
|
82
82
|
const { SPAM_THRESHOLD } = await import("./spam-classifier-VG4ICIWS.js");
|
|
83
83
|
const cfg = opts.url ? { url: opts.url } : readDbConfig();
|
|
84
84
|
const db = await openDb(cfg);
|
|
@@ -119,4 +119,4 @@ export {
|
|
|
119
119
|
rescoreNewSubmissions,
|
|
120
120
|
runSubmissionsCommand
|
|
121
121
|
};
|
|
122
|
-
//# sourceMappingURL=submissions-
|
|
122
|
+
//# sourceMappingURL=submissions-SURN7BV5.js.map
|
|
@@ -11,9 +11,9 @@ import {
|
|
|
11
11
|
} from "./chunk-4BHNP6Y2.js";
|
|
12
12
|
import {
|
|
13
13
|
svelteCodemods
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-LS6QPANO.js";
|
|
15
15
|
import "./chunk-J7SZQUCW.js";
|
|
16
|
-
import "./chunk-
|
|
16
|
+
import "./chunk-ZTPPCHDB.js";
|
|
17
17
|
import "./chunk-FDIFTIXC.js";
|
|
18
18
|
import "./chunk-HVOOCK6L.js";
|
|
19
19
|
import "./chunk-XXTZBPUY.js";
|
|
@@ -51,4 +51,4 @@ async function runSvelteCodemodsCommand(site, opts) {
|
|
|
51
51
|
export {
|
|
52
52
|
runSvelteCodemodsCommand
|
|
53
53
|
};
|
|
54
|
-
//# sourceMappingURL=svelte-codemods-
|
|
54
|
+
//# sourceMappingURL=svelte-codemods-IWO7X3F3.js.map
|
|
@@ -15,12 +15,12 @@ import {
|
|
|
15
15
|
isConfigName,
|
|
16
16
|
mergeGitignore,
|
|
17
17
|
syncConfigs
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-Q276MZ7E.js";
|
|
19
19
|
import {
|
|
20
20
|
ALL_TEMPLATES,
|
|
21
21
|
templatesByName
|
|
22
|
-
} from "./chunk-
|
|
23
|
-
import "./chunk-
|
|
22
|
+
} from "./chunk-73UFRZKY.js";
|
|
23
|
+
import "./chunk-ZTPPCHDB.js";
|
|
24
24
|
import "./chunk-FDIFTIXC.js";
|
|
25
25
|
import "./chunk-HVOOCK6L.js";
|
|
26
26
|
import "./chunk-XXTZBPUY.js";
|
|
@@ -113,4 +113,4 @@ async function runSyncConfigsCommand(site, opts) {
|
|
|
113
113
|
export {
|
|
114
114
|
runSyncConfigsCommand
|
|
115
115
|
};
|
|
116
|
-
//# sourceMappingURL=sync-configs-
|
|
116
|
+
//# sourceMappingURL=sync-configs-7XGHFC6P.js.map
|
|
@@ -16,7 +16,7 @@ type Site = {
|
|
|
16
16
|
};
|
|
17
17
|
type AuditName = "deps" | "lighthouse" | "a11y" | "security" | "lint" | "domain" | "browser" | "netlify-deploy" | "function-health" | "smoke" | "form-e2e";
|
|
18
18
|
type RecipeName = "sync-configs" | "bump-deps" | "svelte-4-to-5" | "svelte-codemods" | "convert-to-pnpm" | "onboard" | "a11y-fixtures-page" | "health-endpoint" | "smoke-suite" | "self-updating" | "init";
|
|
19
|
-
type ConfigName = "lighthouse" | "eslint" | "prettier" | "prettier-ignore" | "playwright-a11y" | "svelte" | "gitignore" | "
|
|
19
|
+
type ConfigName = "lighthouse" | "eslint" | "prettier" | "prettier-ignore" | "playwright-a11y" | "svelte" | "gitignore" | "renovate-action" | "renovate-config" | "netlify";
|
|
20
20
|
type AuditResult = {
|
|
21
21
|
audit: AuditName;
|
|
22
22
|
site: string;
|
|
@@ -11,9 +11,9 @@ import {
|
|
|
11
11
|
} from "./chunk-4BHNP6Y2.js";
|
|
12
12
|
import {
|
|
13
13
|
upgradeSvelte4to5
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-B5ZUQZFL.js";
|
|
15
15
|
import "./chunk-J7SZQUCW.js";
|
|
16
|
-
import "./chunk-
|
|
16
|
+
import "./chunk-ZTPPCHDB.js";
|
|
17
17
|
import "./chunk-OTGB6YQO.js";
|
|
18
18
|
import "./chunk-FDIFTIXC.js";
|
|
19
19
|
import "./chunk-HVOOCK6L.js";
|
|
@@ -60,4 +60,4 @@ async function runUpgradeCommand(upgradeName, site, opts = {}) {
|
|
|
60
60
|
export {
|
|
61
61
|
runUpgradeCommand
|
|
62
62
|
};
|
|
63
|
-
//# sourceMappingURL=upgrade-
|
|
63
|
+
//# sourceMappingURL=upgrade-AM4S7JF3.js.map
|