@reddoorla/maintenance 0.72.0 → 0.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/airtable-6L3JD7FY.js +9 -0
- package/dist/{announce-HJCGEVJA.js → announce-V6EYPPUR.js} +4 -4
- package/dist/{blux-FKZYMKLF.js → blux-C24OVDDW.js} +1044 -69
- package/dist/blux-C24OVDDW.js.map +1 -0
- package/dist/{bump-deps-GZEJAUKI.js → bump-deps-OQR3IXLN.js} +2 -2
- package/dist/chunk-2OP4JKIQ.js +19 -0
- package/dist/chunk-2OP4JKIQ.js.map +1 -0
- package/dist/{chunk-BJCA7O4A.js → chunk-3PU2QWOJ.js} +2 -2
- package/dist/{chunk-GI4M2AMO.js → chunk-4BHNP6Y2.js} +2 -2
- package/dist/{chunk-KRHPS3R4.js → chunk-6OYICQSJ.js} +47 -3
- package/dist/chunk-6OYICQSJ.js.map +1 -0
- package/dist/{chunk-BWKK2ZXS.js → chunk-GXVX4HUH.js} +32 -2
- package/dist/chunk-GXVX4HUH.js.map +1 -0
- package/dist/{chunk-KLB2OLPW.js → chunk-HBFMTM2E.js} +3 -3
- package/dist/{chunk-SQJ53Z7R.js → chunk-I55YWV6T.js} +3 -3
- package/dist/{chunk-7P7YETHG.js → chunk-IDBMYDVH.js} +2 -2
- package/dist/{chunk-JGLAWXUC.js → chunk-JR67FZVC.js} +7 -3
- package/dist/chunk-JR67FZVC.js.map +1 -0
- package/dist/{chunk-APTAITXZ.js → chunk-KKL3RYFQ.js} +2 -2
- package/dist/{chunk-NRISG4XD.js → chunk-NUIBQPB3.js} +42 -11
- package/dist/chunk-NUIBQPB3.js.map +1 -0
- package/dist/{chunk-BHE5CUWI.js → chunk-PERP7LSS.js} +269 -18
- package/dist/chunk-PERP7LSS.js.map +1 -0
- package/dist/chunk-RHPDQW6D.js +29 -0
- package/dist/chunk-RHPDQW6D.js.map +1 -0
- package/dist/{chunk-HR5SONJC.js → chunk-SK44IFZN.js} +2 -2
- package/dist/cli/bin.js +31 -19
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +6 -6
- package/dist/{convert-to-pnpm-SVFDLXZS.js → convert-to-pnpm-OVMASATZ.js} +2 -2
- package/dist/{digest-VX7Q4MNF.js → digest-LEMZS5ZK.js} +99 -7
- package/dist/digest-LEMZS5ZK.js.map +1 -0
- package/dist/{ensure-site-O7G5ZJS7.js → ensure-site-3SOAWTZF.js} +2 -2
- package/dist/forms/index.js +1 -1
- package/dist/{github-signals-QVW2LB7N.js → github-signals-TF3VSNIN.js} +4 -4
- package/dist/{health-endpoint-IYR5VBUT.js → health-endpoint-JZGYCMTV.js} +2 -2
- package/dist/index.d.ts +104 -19
- package/dist/index.js +122 -30
- package/dist/index.js.map +1 -1
- package/dist/{init-CU6PVZRG.js → init-7PTP4O2K.js} +4 -4
- package/dist/{launch-JWXCTJRG.js → launch-W33FWWOZ.js} +6 -6
- package/dist/{lighthouse-airtable-J5TLASJC.js → lighthouse-airtable-ZXNMDEPE.js} +3 -3
- package/dist/{onboard-6UQE5QIO.js → onboard-PX36YDZA.js} +2 -2
- package/dist/{orchestrate-ZDH24Z6Y.js → orchestrate-NYRYEKQM.js} +3 -3
- package/dist/{preflight-BNTMWTPG.js → preflight-JCQFKB6M.js} +4 -4
- package/dist/{renovate-dispatch-C3ALLACX.js → renovate-dispatch-6II2JVOX.js} +29 -27
- package/dist/renovate-dispatch-6II2JVOX.js.map +1 -0
- package/dist/{report-YLFSGNWR.js → report-POHQEV6F.js} +21 -5
- package/dist/report-POHQEV6F.js.map +1 -0
- package/dist/screenouts-7P5GOZMV.js +57 -0
- package/dist/screenouts-7P5GOZMV.js.map +1 -0
- package/dist/{self-updating-64FNRQB5.js → self-updating-2YSVNMPP.js} +2 -2
- package/dist/{selftest-7DPNGL6R.js → selftest-OO2WDSDM.js} +5 -5
- package/dist/{smoke-suite-TWILWDSF.js → smoke-suite-CSP23NZM.js} +2 -2
- package/dist/spam-classifier-VG4ICIWS.js +217 -0
- package/dist/spam-classifier-VG4ICIWS.js.map +1 -0
- package/dist/submissions-BNRGY3JQ.js +122 -0
- package/dist/submissions-BNRGY3JQ.js.map +1 -0
- package/dist/submissions-MDKWJYMM.js +281 -0
- package/dist/submissions-MDKWJYMM.js.map +1 -0
- package/dist/{svelte-codemods-RVDTTXFD.js → svelte-codemods-V54KOBWU.js} +2 -2
- package/dist/{sync-configs-BUUD6L5Q.js → sync-configs-UBCJRASZ.js} +2 -2
- package/dist/{upgrade-HHKWYJRN.js → upgrade-WR6ICPAG.js} +2 -2
- package/dist/{websites-EH2ULELZ.js → websites-U6642XSE.js} +10 -2
- package/dist/{write-audits-to-airtable-LZDYTNX5.js → write-audits-to-airtable-22T6D733.js} +4 -4
- package/package.json +1 -1
- package/dist/airtable-U2GTDTK5.js +0 -9
- package/dist/blux-FKZYMKLF.js.map +0 -1
- package/dist/chunk-BHE5CUWI.js.map +0 -1
- package/dist/chunk-BWKK2ZXS.js.map +0 -1
- package/dist/chunk-JGLAWXUC.js.map +0 -1
- package/dist/chunk-KRHPS3R4.js.map +0 -1
- package/dist/chunk-NRISG4XD.js.map +0 -1
- package/dist/chunk-QTHKOIFJ.js +0 -13
- package/dist/chunk-QTHKOIFJ.js.map +0 -1
- package/dist/digest-VX7Q4MNF.js.map +0 -1
- package/dist/renovate-dispatch-C3ALLACX.js.map +0 -1
- package/dist/report-YLFSGNWR.js.map +0 -1
- /package/dist/{airtable-U2GTDTK5.js.map → airtable-6L3JD7FY.js.map} +0 -0
- /package/dist/{announce-HJCGEVJA.js.map → announce-V6EYPPUR.js.map} +0 -0
- /package/dist/{bump-deps-GZEJAUKI.js.map → bump-deps-OQR3IXLN.js.map} +0 -0
- /package/dist/{chunk-BJCA7O4A.js.map → chunk-3PU2QWOJ.js.map} +0 -0
- /package/dist/{chunk-GI4M2AMO.js.map → chunk-4BHNP6Y2.js.map} +0 -0
- /package/dist/{chunk-KLB2OLPW.js.map → chunk-HBFMTM2E.js.map} +0 -0
- /package/dist/{chunk-SQJ53Z7R.js.map → chunk-I55YWV6T.js.map} +0 -0
- /package/dist/{chunk-7P7YETHG.js.map → chunk-IDBMYDVH.js.map} +0 -0
- /package/dist/{chunk-APTAITXZ.js.map → chunk-KKL3RYFQ.js.map} +0 -0
- /package/dist/{chunk-HR5SONJC.js.map → chunk-SK44IFZN.js.map} +0 -0
- /package/dist/{convert-to-pnpm-SVFDLXZS.js.map → convert-to-pnpm-OVMASATZ.js.map} +0 -0
- /package/dist/{ensure-site-O7G5ZJS7.js.map → ensure-site-3SOAWTZF.js.map} +0 -0
- /package/dist/{github-signals-QVW2LB7N.js.map → github-signals-TF3VSNIN.js.map} +0 -0
- /package/dist/{health-endpoint-IYR5VBUT.js.map → health-endpoint-JZGYCMTV.js.map} +0 -0
- /package/dist/{init-CU6PVZRG.js.map → init-7PTP4O2K.js.map} +0 -0
- /package/dist/{launch-JWXCTJRG.js.map → launch-W33FWWOZ.js.map} +0 -0
- /package/dist/{lighthouse-airtable-J5TLASJC.js.map → lighthouse-airtable-ZXNMDEPE.js.map} +0 -0
- /package/dist/{onboard-6UQE5QIO.js.map → onboard-PX36YDZA.js.map} +0 -0
- /package/dist/{orchestrate-ZDH24Z6Y.js.map → orchestrate-NYRYEKQM.js.map} +0 -0
- /package/dist/{preflight-BNTMWTPG.js.map → preflight-JCQFKB6M.js.map} +0 -0
- /package/dist/{self-updating-64FNRQB5.js.map → self-updating-2YSVNMPP.js.map} +0 -0
- /package/dist/{selftest-7DPNGL6R.js.map → selftest-OO2WDSDM.js.map} +0 -0
- /package/dist/{smoke-suite-TWILWDSF.js.map → smoke-suite-CSP23NZM.js.map} +0 -0
- /package/dist/{svelte-codemods-RVDTTXFD.js.map → svelte-codemods-V54KOBWU.js.map} +0 -0
- /package/dist/{sync-configs-BUUD6L5Q.js.map → sync-configs-UBCJRASZ.js.map} +0 -0
- /package/dist/{upgrade-HHKWYJRN.js.map → upgrade-WR6ICPAG.js.map} +0 -0
- /package/dist/{websites-EH2ULELZ.js.map → websites-U6642XSE.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-LZDYTNX5.js.map → write-audits-to-airtable-22T6D733.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/digest.ts"],"sourcesContent":["// src/reports/digest.ts\nimport { openBase, readAirtableConfig, type AirtableBase } from \"./airtable/client.js\";\nimport { listAllReports, isPendingApproval } from \"./airtable/reports.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport { listWebsites, siteSlug, type WebsiteRow } from \"./airtable/websites.js\";\nimport { defaultResendClient, type ResendClient } from \"./send/resend.js\";\nimport { isIdempotencyConflict } from \"./send/idempotency.js\";\nimport {\n collectVulnAlerts,\n collectDeliveryFailures,\n collectPreflightBlocked,\n collectLighthouseAlerts,\n collectRenovateAlerts,\n collectCiAlerts,\n collectAnalyticsFailures,\n collectTurnstileGuardrailAlerts,\n collectNotifyBounceAlerts,\n NOTIFY_BOUNCE_WINDOW_DAYS,\n} from \"../alerts/digest-collectors.js\";\nimport { diffAttention, readDigestState, writeDigestState } from \"../alerts/digest-state.js\";\nimport { escapeHtml as esc } from \"../util/html.js\";\nimport type {\n AttentionItem,\n AttentionSeverity,\n AttentionStatus,\n ReadyItem,\n DigestSections,\n SubmissionsDigestSection,\n} from \"../alerts/attention.js\";\n// Type-only: erased at compile time, so the kysely/libsql devDependency never loads\n// in a consuming fleet site (the runtime read is the dynamic import in\n// fetchSubmissionsDigestCounts, same rule as fetchNotifyBounceCounts).\nimport type { SiteSubmissionCounts } from \"../db/submissions.js\";\n\n// The attention/digest contract lives in `../alerts/attention.ts` (a dependency-free\n// types module) so the `alerts/*` collectors can depend on it without importing back\n// from this renderer/IO module — see attention.ts for the cycle it breaks. Re-exported\n// here so existing `from \"./digest.js\"` type importers keep resolving.\nexport type {\n AttentionItem,\n AttentionSeverity,\n AttentionStatus,\n ReadyItem,\n DigestSections,\n SubmissionsDigestSection,\n} from \"../alerts/attention.js\";\n\nconst GREY = \"#757575\";\nconst RED = \"#C00\";\n\n/** Shared anchor style — Gmail does not inherit font-family into <a> tags. */\nconst ANCHOR_STYLE = `color:${RED};font-family:helvetica,sans-serif`;\n\nfunction readySection(items: ReadyItem[]): string {\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Ready for your yes</h2>`;\n if (items.length === 0) {\n return `${heading}<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">Nothing waiting on you.</p>`;\n }\n const rows = items\n .map((it) => {\n const safeUrl = it.dashboardUrl.startsWith(\"https://\") ? it.dashboardUrl : undefined;\n const link = safeUrl\n ? `<a href=\"${esc(safeUrl)}\" style=\"${ANCHOR_STYLE}\">review & approve</a>`\n : `review & approve`;\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">\n <strong style=\"color:#222\">${esc(it.siteName)}</strong> — ${esc(it.reportType)} (${esc(it.period)})\n — ${link}\n </td>\n </tr>`;\n })\n .join(\"\");\n return `${heading}<table role=\"presentation\" style=\"border-collapse:collapse;margin:0\">${rows}</table>`;\n}\n\nconst SEVERITY_ORDER: Record<AttentionSeverity, number> = { critical: 0, warning: 1 };\n\n/** Render the per-item status badge (\"NEW\"/\"WORSE\"); standing items get nothing. */\nfunction attentionBadge(status?: AttentionStatus): string {\n if (status === \"new\")\n return `<strong style=\"color:${RED};font-family:helvetica,sans-serif\">NEW</strong> `;\n if (status === \"worse\")\n return `<strong style=\"color:${RED};font-family:helvetica,sans-serif\">WORSE</strong> `;\n return \"\";\n}\n\nfunction attentionSection(items: AttentionItem[]): string {\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Needs attention</h2>`;\n if (items.length === 0) {\n return `${heading}<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">All clear — nothing needs attention.</p>`;\n }\n\n // Group by siteName, preserving first-seen site order; sort within a site by\n // severity (critical first).\n const bySite = new Map<string, AttentionItem[]>();\n for (const it of items) {\n const bucket = bySite.get(it.siteName);\n if (bucket) bucket.push(it);\n else bySite.set(it.siteName, [it]);\n }\n\n const groups = [...bySite.entries()]\n .map(([siteName, siteItems]) => {\n const sorted = [...siteItems].sort(\n (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity],\n );\n const rows = sorted\n .map((it) => {\n const safeUrl = it.url?.startsWith(\"https://\") ? it.url : undefined;\n const titleHtml = safeUrl\n ? `<a href=\"${esc(safeUrl)}\" style=\"${ANCHOR_STYLE}\">${esc(it.title)}</a>`\n : esc(it.title);\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">${attentionBadge(it.status)}${titleHtml}</td>\n </tr>`;\n })\n .join(\"\");\n return `\n <tr>\n <td style=\"color:#222;font-family:helvetica,sans-serif;font-size:16px;font-weight:700;padding:8px 0 4px\">${esc(siteName)}</td>\n </tr>\n ${rows}`;\n })\n .join(\"\");\n\n return `${heading}<table role=\"presentation\" style=\"border-collapse:collapse;margin:0\">${groups}</table>`;\n}\n\n/** The \"Submissions (24h)\" telemetry block: fleet totals + a per-site glance line.\n * Context, not an alarm — absent/null section or all-zero totals render nothing\n * (no-noise), and the section never triggers a send on its own (see runDigest). */\nfunction submissionsSection(s: SubmissionsDigestSection | null | undefined): string {\n if (!s) return \"\";\n if (s.leads === 0 && s.signups === 0 && s.spamAuto === 0) return \"\";\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Submissions (24h)</h2>`;\n // All three terms always render together — omitting a zero term next to a nonzero\n // one reads as \"not measured\" rather than \"none\"; simplest honest form.\n const totals = [\n `${s.leads} new lead${s.leads === 1 ? \"\" : \"s\"}`,\n `${s.signups} newsletter/RSVP signup${s.signups === 1 ? \"\" : \"s\"}`,\n `${s.spamAuto} auto-filtered spam`,\n ].join(\" · \");\n const totalLine = `<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">${totals}</p>`;\n if (s.bySite.length === 0) return `${heading}${totalLine}`;\n const rows = s.bySite\n .map((site) => {\n const parts: string[] = [];\n if (site.leads > 0) parts.push(`${site.leads} lead${site.leads === 1 ? \"\" : \"s\"}`);\n if (site.signups > 0) parts.push(`${site.signups} signup${site.signups === 1 ? \"\" : \"s\"}`);\n if (site.spamAuto > 0) parts.push(`${site.spamAuto} auto-filtered`);\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">\n <strong style=\"color:#222\">${esc(site.siteName)}</strong> — ${parts.join(\" · \")}\n </td>\n </tr>`;\n })\n .join(\"\");\n return `${heading}${totalLine}<table role=\"presentation\" style=\"border-collapse:collapse;margin:8px 0 0\">${rows}</table>`;\n}\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\n/** Single-operator fleet — fallback when OPERATOR_EMAIL is unset. */\nconst DIGEST_OPERATOR_FALLBACK = \"info@reddoorla.com\";\n\n/** UTC \"YYYY-MM-DD\" — the Resend idempotency key suffix, so a same-day cron re-fire dedupes. */\nfunction digestDateKey(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * The gate for \"Ready for your yes\": Draft ready ∧ ¬Approved to send ∧ Sent at BLANK.\n *\n * Implemented as `listAllReports(base).filter(...)` (the authorized deviation from the plan's\n * draft, which pre-dated Slice 1's merged fix): `listAllReports` already calls `mapRow`, which\n * handles `Period` correctly, so no local `rawToReportRow` duplicate is needed. The JS filter\n * does real work — the test fake does NOT evaluate `filterByFormula` — so correctness is\n * test-provable here.\n *\n * Exported: the fleet homepage (Task 3.5b) reuses it for the pending-approval count.\n */\nexport async function listPendingApproval(base: AirtableBase): Promise<ReportRow[]> {\n return (await listAllReports(base)).filter(isPendingApproval);\n}\n\n// ── collectAttention (IO wrapper, sibling to runDigest) ──────────────────────\n\nexport type CollectAttentionDeps = {\n base: AirtableBase;\n /** Same baseUrl value runDigest threads; used for the /s/<slug> links. */\n baseUrl: string;\n /** Pre-fetched Websites rows. When supplied (runDigest already read them),\n * collectAttention reuses them instead of issuing a second `listWebsites`. */\n websites?: WebsiteRow[];\n /** Pre-fetched Reports rows. When supplied (runDigest already read them),\n * collectAttention reuses them instead of issuing a second `listAllReports`. */\n reports?: ReportRow[];\n /** Clock for the GitHub-signals staleness gate (collectCiAlerts /\n * collectRenovateAlerts skip a >3-day-stale sweep). Defaults to wall-clock;\n * runDigest threads its run-start `today` so a quiet repo's frozen CI/Renovate\n * signal stops badging once its sweep goes stale. */\n now?: Date;\n /** Pre-fetched per-site bounced-lead-notification counts (tests, or a caller with\n * an open db). When omitted, collectAttention reads them from libSQL itself —\n * defensively, so a missing TURSO env or a Turso blip drops just this signal. */\n notifyBounces?: ReadonlyMap<string, number>;\n};\n\n/** Per-site bounced-notification counts for the notify-bounce collector, read from\n * libSQL over the collector's own window. The db modules are imported DYNAMICALLY:\n * src/db/submissions.ts pulls kysely at top level, and kysely/libsql live in\n * devDependencies consuming fleet sites don't install — a static import here would\n * crash their CLI at require time (same rule as openDb's own lazy loads). Any\n * failure (no TURSO env, Turso down) logs and yields an empty map: the digest\n * must never blank over a missing optional signal. */\nasync function fetchNotifyBounceCounts(now: Date): Promise<ReadonlyMap<string, number>> {\n try {\n const [{ openDb, readDbConfig }, { countNotifyBouncedBySite }, { screenOutsSince }] =\n await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/submissions.js\"),\n import(\"../db/screenouts.js\"),\n ]);\n const db = await openDb(readDbConfig());\n return await countNotifyBouncedBySite(db, screenOutsSince(now, NOTIFY_BOUNCE_WINDOW_DAYS));\n } catch (e) {\n console.warn(`⚠ notify-bounce counts unavailable (libSQL): ${(e as Error).message}`);\n return new Map();\n }\n}\n\n/** The \"Submissions (24h)\" telemetry window — a precise 24h ISO-timestamp compare\n * (unlike screenOutsSince's date-only strings). */\nconst SUBMISSIONS_DIGEST_WINDOW_MS = 24 * 60 * 60 * 1000;\n\n/** Per-site submission counts for the digest telemetry section, read from libSQL.\n * Dynamic imports + fail-soft for the same reason as fetchNotifyBounceCounts:\n * kysely/libsql are devDependencies consuming fleet sites don't install, and a\n * Turso blip must drop just this section, never the digest. Returns null (not an\n * empty map) on failure so the renderer can omit the section rather than claim a\n * quiet fleet. */\nasync function fetchSubmissionsDigestCounts(\n now: Date,\n): Promise<ReadonlyMap<string, SiteSubmissionCounts> | null> {\n try {\n const [{ openDb, readDbConfig }, { countSubmissionsSinceBySite }] = await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/submissions.js\"),\n ]);\n const db = await openDb(readDbConfig());\n return await countSubmissionsSinceBySite(\n db,\n new Date(now.getTime() - SUBMISSIONS_DIGEST_WINDOW_MS).toISOString(),\n );\n } catch (e) {\n console.warn(`⚠ submissions telemetry unavailable (libSQL): ${(e as Error).message}`);\n return null;\n }\n}\n\n/** Assemble the \"Submissions (24h)\" section from the per-site counts. PURE (exported\n * for direct tests). Totals sum ALL map entries — an orphan site id still counts in\n * the fleet totals. bySite lists only ids that resolve in `sitesById` AND carry any\n * nonzero count, sorted by total volume desc then siteName A-Z. null counts (libSQL\n * unavailable) → null (section omitted). */\nexport function buildSubmissionsDigestSection(\n counts: ReadonlyMap<string, SiteSubmissionCounts> | null,\n sitesById: ReadonlyMap<string, WebsiteRow>,\n): SubmissionsDigestSection | null {\n if (counts === null) return null;\n let leads = 0;\n let signups = 0;\n let spamAuto = 0;\n const bySite: SubmissionsDigestSection[\"bySite\"] = [];\n for (const [siteId, c] of counts) {\n leads += c.leads;\n signups += c.signups;\n spamAuto += c.spamAuto;\n const site = sitesById.get(siteId);\n if (site && c.leads + c.signups + c.spamAuto > 0) {\n bySite.push({ siteName: site.name, ...c });\n }\n }\n bySite.sort(\n (a, b) =>\n b.leads + b.signups + b.spamAuto - (a.leads + a.signups + a.spamAuto) ||\n a.siteName.localeCompare(b.siteName),\n );\n return { leads, signups, spamAuto, bySite };\n}\n\n/** Run a single collector under a try/catch: a thrown collector logs and yields []\n * so one broken signal never blanks the whole \"Needs attention\" section. */\nfunction runCollector(label: string, fn: () => AttentionItem[]): AttentionItem[] {\n try {\n return fn();\n } catch (e) {\n console.warn(`⚠ attention collector \"${label}\" failed: ${(e as Error).message}`);\n return [];\n }\n}\n\n/**\n * Fetch the free signals once (listAllReports + listWebsites) — or reuse the\n * `reports`/`websites` arrays runDigest already read, so a single run reads each\n * table once — build the sitesById map the delivery collector needs, and run each\n * pure collector isolated. Returns the union of items; diffing/badging happens in\n * runDigest.\n *\n * The Renovate + CI signals come from the SAME persisted collectors the operator\n * cockpit (`buildCockpitModel`) runs — `collectRenovateAlerts` (key\n * `renovate:<siteId>`) and `collectCiAlerts` (key `ci:<siteId>`), reading the\n * nightly-persisted `renovateFailingCis`/`defaultBranchCi` fields. This is the\n * key-space unification: because the digest writes the shared Digest State\n * snapshot with these same keys, the cockpit's NEW/WORSE diff finds them and the\n * two surfaces agree (the prior live per-PR `renovate:<repo>#<n>` sweep never\n * matched the cockpit's keys, so its cards badged NEW forever).\n */\nexport async function collectAttention(deps: CollectAttentionDeps): Promise<AttentionItem[]> {\n const reports = deps.reports ?? (await listAllReports(deps.base));\n const websites = deps.websites ?? (await listWebsites(deps.base));\n const now = deps.now ?? new Date();\n const notifyBounces = deps.notifyBounces ?? (await fetchNotifyBounceCounts(now));\n const sitesById = new Map<string, WebsiteRow>(websites.map((w) => [w.id, w]));\n return [\n ...runCollector(\"vuln\", () => collectVulnAlerts(websites, deps.baseUrl)),\n ...runCollector(\"delivery\", () => collectDeliveryFailures(reports, sitesById, deps.baseUrl)),\n ...runCollector(\"preflight\", () => collectPreflightBlocked(reports, sitesById, deps.baseUrl)),\n ...runCollector(\"lighthouse\", () => collectLighthouseAlerts(websites, deps.baseUrl)),\n ...runCollector(\"renovate\", () => collectRenovateAlerts(websites, deps.baseUrl, now)),\n ...runCollector(\"ci\", () => collectCiAlerts(websites, deps.baseUrl, now)),\n ...runCollector(\"analytics\", () => collectAnalyticsFailures(websites, deps.baseUrl, now)),\n ...runCollector(\"turnstile\", () =>\n collectTurnstileGuardrailAlerts(websites, deps.baseUrl, now),\n ),\n ...runCollector(\"notify-bounce\", () =>\n collectNotifyBounceAlerts(websites, notifyBounces, deps.baseUrl),\n ),\n ];\n}\n\nexport type DigestRunOptions = {\n resend?: ResendClient;\n /** Dashboard origin for the /s/<slug> links, e.g. \"https://reddoor-maintenance.netlify.app\". */\n baseUrl: string;\n /**\n * Inject a pre-opened Airtable base (tests, server handlers).\n * When omitted, `openBase(readAirtableConfig())` is called from the environment.\n */\n base?: AirtableBase;\n /** Per-site submission counts for the \"Submissions (24h)\" section. undefined =\n * fetch from libSQL; null = simulate unavailable (section omitted); a Map =\n * injected (tests). */\n submissionCounts?: ReadonlyMap<string, SiteSubmissionCounts> | null;\n};\n\nexport async function runDigest(\n options: DigestRunOptions,\n): Promise<{ output: string; code: number }> {\n // Capture clock BEFORE any await so the idempotency key can't roll past midnight mid-run.\n const today = new Date();\n try {\n const base = options.base ?? openBase(readAirtableConfig());\n // Read each table ONCE for the whole run, then thread the arrays into\n // collectAttention so it doesn't re-fetch (was: listWebsites ×2, listAllReports\n // ×2). Pending is derived in-line with listPendingApproval's exact predicate.\n const reports = await listAllReports(base);\n const websites = await listWebsites(base);\n const sites = new Map(websites.map((w) => [w.id, w]));\n\n const pending = reports.filter(isPendingApproval);\n\n const readyForYourYes: ReadyItem[] = [];\n const baseUrl = options.baseUrl.replace(/\\/$/, \"\");\n for (const r of pending) {\n const site = sites.get(r.siteId);\n if (!site) continue; // orphan report → skip rather than render a broken link\n // An empty Name slugs to \"\" → `/s/` is a dead link (getWebsiteBySlug can't\n // match it). Fall back to the fleet homepage so the operator still lands\n // somewhere usable instead of a 404.\n const slug = siteSlug(site.name);\n readyForYourYes.push({\n siteName: site.name,\n reportType: r.reportType,\n period: r.period ?? \"—\",\n dashboardUrl: slug ? `${baseUrl}/s/${slug}` : baseUrl,\n });\n }\n\n // M5: collect the free signals (isolated), diff against yesterday's snapshot.\n // Renovate + CI come from the persisted collectors (the same ones the cockpit\n // runs), so the snapshot this digest writes carries the `renovate:<siteId>` /\n // `ci:<siteId>` keys the cockpit diffs against — no live GitHub sweep here.\n const collected = await collectAttention({\n base,\n baseUrl: options.baseUrl,\n websites,\n reports,\n now: today,\n });\n const prior = await readDigestState(base);\n const { tagged, next } = diffAttention(collected, prior, digestDateKey(today));\n const needsAttention = tagged;\n\n // No-noise default: skip entirely when there's nothing to report.\n if (readyForYourYes.length === 0 && needsAttention.length === 0) {\n // On a skip, `collected` is [] so `next` is {} — still persist it so a key that\n // resolved on a quiet day clears and a later recurrence diffs as NEW (spec §10).\n // Wrapped: a write failure can't fail the skip.\n try {\n await writeDigestState(base, next);\n } catch (e) {\n console.warn(`⚠ digest state write failed: ${(e as Error).message}`);\n }\n return { output: \"Digest skipped (nothing ready, nothing needs attention).\", code: 0 };\n }\n\n // Submissions telemetry rides only on a digest that is ALREADY sending — the\n // skip predicate above is deliberately unchanged (leads already fire their own\n // ingest-time notification; this is context, not an alarm), and fetching AFTER\n // the skip check keeps quiet days from touching libSQL at all.\n const counts =\n options.submissionCounts !== undefined\n ? options.submissionCounts\n : await fetchSubmissionsDigestCounts(today);\n const submissions = buildSubmissionsDigestSection(counts, sites);\n\n const html = renderDigestHtml({ readyForYourYes, needsAttention, submissions });\n const client = options.resend ?? defaultResendClient();\n const to = [process.env.OPERATOR_EMAIL?.trim() || DIGEST_OPERATOR_FALLBACK];\n const n = readyForYourYes.length;\n const reportWord = n === 1 ? \"report\" : \"reports\";\n let result: Awaited<ReturnType<typeof client.send>>;\n try {\n result = await client.send({\n from: FROM_ADDRESS,\n to,\n subject: `Your fleet — ${digestDateKey(today)}: ${n} ${reportWord} ready for your yes`,\n html,\n idempotencyKey: `digest-${digestDateKey(today)}`,\n });\n } catch (err) {\n // A same-UTC-day re-run whose content changed re-sends with the same\n // `digest-<date>` idempotency key but a DIFFERENT body. Resend rejects that\n // with a 409 (`invalid_idempotent_request`) — \"This idempotency key has been\n // used ... but the request body was modified ...\". The operator already got\n // today's digest on the first send, so re-sending a changed version same-day\n // would just be a duplicate: treat it as an \"already sent today\" no-op.\n //\n // ResendClient (send/resend.ts) wraps the API error as a plain Error and only\n // preserves the *message* string (no name/statusCode), so the message\n // substring is the only reliable discriminator — match defensively on it.\n // Any OTHER send error re-throws to the outer catch → {code:1}, so a genuine\n // Resend/network failure still fails loudly.\n if (isIdempotencyConflict(err)) {\n // Do NOT write the snapshot: the first send already persisted it; writing\n // this run's `next` would diff against the first run's snapshot and mis-badge.\n return {\n output:\n \"Digest already sent today (content changed since the first send) — skipped to avoid a duplicate.\",\n code: 0,\n };\n }\n throw err;\n }\n // Persist the next snapshot AFTER a successful send. A write failure is caught +\n // logged: the digest already went out, tomorrow re-news at worst. (The send-FAILURE\n // path never reaches here — the outer catch returns code 1 with no write, preserving\n // the NEW badge for the retry.)\n try {\n await writeDigestState(base, next);\n } catch (e) {\n console.warn(`⚠ digest state write failed: ${(e as Error).message}`);\n }\n return { output: `Digest sent to ${to.join(\", \")} (${result.messageId})`, code: 0 };\n } catch (err) {\n // Re-throw config errors (exitCode=2: missing env vars, bad config) so runOrExit\n // surfaces them with the correct process exit code rather than collapsing to 1.\n if (typeof (err as { exitCode?: unknown }).exitCode === \"number\") {\n throw err;\n }\n const message = err instanceof Error ? err.message : String(err);\n return { output: `digest failed: ${message}`, code: 1 };\n }\n}\n\n/** Pure render of the unified daily operator digest. No IO — the caller (runDigest)\n * collects the rows and decides whether to send. */\nexport function renderDigestHtml(sections: DigestSections): string {\n return `<!doctype html>\n<html>\n <head><meta charset=\"utf-8\"></head>\n <body style=\"margin:0;padding:0;background:#ffffff\">\n <table width=\"100%\" style=\"border-collapse:collapse\">\n <tr>\n <td align=\"center\" style=\"padding:24px\">\n <table width=\"600\" style=\"border-collapse:collapse\">\n <tr>\n <td>\n <h1 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px\">Your fleet today</h1>\n ${readySection(sections.readyForYourYes)}\n ${attentionSection(sections.needsAttention)}\n ${submissionsSection(sections.submissions)}\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </body>\n</html>`;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
WEBSITES_TABLE,
|
|
3
3
|
listWebsites,
|
|
4
4
|
siteSlug
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-GXVX4HUH.js";
|
|
6
6
|
import {
|
|
7
7
|
openBase,
|
|
8
8
|
readAirtableConfig
|
|
@@ -84,4 +84,4 @@ async function runEnsureSiteCommand(slug, opts) {
|
|
|
84
84
|
export {
|
|
85
85
|
runEnsureSiteCommand
|
|
86
86
|
};
|
|
87
|
-
//# sourceMappingURL=ensure-site-
|
|
87
|
+
//# sourceMappingURL=ensure-site-3SOAWTZF.js.map
|
package/dist/forms/index.js
CHANGED
|
@@ -3,8 +3,8 @@ import {
|
|
|
3
3
|
} from "./chunk-PZQ3WED2.js";
|
|
4
4
|
import {
|
|
5
5
|
formatFleetWriteSummary
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
import "./chunk-
|
|
6
|
+
} from "./chunk-JR67FZVC.js";
|
|
7
|
+
import "./chunk-3PU2QWOJ.js";
|
|
8
8
|
import {
|
|
9
9
|
detectSignalEvents,
|
|
10
10
|
fleetSweptEvent
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
listWebsites,
|
|
23
23
|
siteSlug,
|
|
24
24
|
updateGitHubSignals
|
|
25
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-GXVX4HUH.js";
|
|
26
26
|
import {
|
|
27
27
|
openBase,
|
|
28
28
|
readAirtableConfig
|
|
@@ -134,4 +134,4 @@ export {
|
|
|
134
134
|
githubSignalsExitCode,
|
|
135
135
|
runGitHubSignalsCommand
|
|
136
136
|
};
|
|
137
|
-
//# sourceMappingURL=github-signals-
|
|
137
|
+
//# sourceMappingURL=github-signals-TF3VSNIN.js.map
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "./chunk-4NAOR44V.js";
|
|
9
9
|
import {
|
|
10
10
|
resolveSites
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-4BHNP6Y2.js";
|
|
12
12
|
import {
|
|
13
13
|
healthEndpoint
|
|
14
14
|
} from "./chunk-7MJBTV2R.js";
|
|
@@ -51,4 +51,4 @@ async function runHealthEndpointCommand(site, opts) {
|
|
|
51
51
|
export {
|
|
52
52
|
runHealthEndpointCommand
|
|
53
53
|
};
|
|
54
|
-
//# sourceMappingURL=health-endpoint-
|
|
54
|
+
//# sourceMappingURL=health-endpoint-JZGYCMTV.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -46,15 +46,22 @@ type DiscoverDeps = {
|
|
|
46
46
|
* opened — no extra navigation). */
|
|
47
47
|
type RouteResult = {
|
|
48
48
|
url: string;
|
|
49
|
-
/** Per desktop engine (chromium/firefox/webkit): loaded with no JS error + a visible main
|
|
49
|
+
/** Per desktop engine (chromium/firefox/webkit): loaded with no JS error + a visible main
|
|
50
|
+
* landmark. `status` = the HTTP status of THAT engine's navigation (null = the nav threw:
|
|
51
|
+
* timeout/crash); optional because it wasn't always captured — undefined is never excusable.
|
|
52
|
+
* NOTE: `ok` may be an EXCUSAL, not proof of a render — excuseChallengedEngineChecks flips a
|
|
53
|
+
* WAF-challenged entry to ok:true on a reachable route (see details.excusedEngineChecks). */
|
|
50
54
|
desktop: Array<{
|
|
51
55
|
engine: string;
|
|
52
56
|
ok: boolean;
|
|
57
|
+
status?: number | null;
|
|
53
58
|
}>;
|
|
54
|
-
/** Per mobile device: loaded with no JS error and no horizontal overflow.
|
|
59
|
+
/** Per mobile device: loaded with no JS error and no horizontal overflow. Same `status`
|
|
60
|
+
* semantics as desktop, and the same excusal caveat on `ok`. */
|
|
55
61
|
mobile: Array<{
|
|
56
62
|
device: string;
|
|
57
63
|
ok: boolean;
|
|
64
|
+
status?: number | null;
|
|
58
65
|
}>;
|
|
59
66
|
/** Same-origin links discovered on the page (absolute URLs), for the Links check. */
|
|
60
67
|
links: string[];
|
|
@@ -75,6 +82,16 @@ type BrowserRunner = {
|
|
|
75
82
|
checkLinks: (urls: string[]) => Promise<LinkResult[]>;
|
|
76
83
|
close?: () => Promise<void>;
|
|
77
84
|
};
|
|
85
|
+
/** What a plain (non-browser) GET of a route observed. `status: null` = network error/timeout. */
|
|
86
|
+
type PageFetch = {
|
|
87
|
+
status: number | null;
|
|
88
|
+
title: string | null;
|
|
89
|
+
metaDescription: string | null;
|
|
90
|
+
};
|
|
91
|
+
/** Injected plain-fetch re-verification IO (see reverifyRoutes). Tests pass a fake. */
|
|
92
|
+
type VerifyDeps = {
|
|
93
|
+
fetchPage: (url: string) => Promise<PageFetch>;
|
|
94
|
+
};
|
|
78
95
|
|
|
79
96
|
/** Outcome of driving one site's contact form. `formPresent:false` ⇒ n/a
|
|
80
97
|
* (persisted). `testModeUndeclared` ⇒ the site's /health does not declare
|
|
@@ -210,6 +227,10 @@ type AuditContext = {
|
|
|
210
227
|
discoverDeps?: DiscoverDeps;
|
|
211
228
|
/** Playwright runner injection for the browser audit (tests). Defaults to real Playwright. */
|
|
212
229
|
browserRunner?: BrowserRunner;
|
|
230
|
+
/** Plain-fetch re-verification injection for the browser audit (tests). Defaults to real fetch.
|
|
231
|
+
* Re-checks any route the browser probe called unreachable / title-less before a fail verdict
|
|
232
|
+
* can persist (WAF challenges hit headless browsers, not plain fetches). */
|
|
233
|
+
verifyDeps?: VerifyDeps;
|
|
213
234
|
/** Playwright runner injection for the form-e2e audit (tests). Defaults to real Playwright. */
|
|
214
235
|
formRunner?: FormRunner;
|
|
215
236
|
/** Netlify API injection for the netlify-deploy audit (tests). Defaults to a real API call. */
|
|
@@ -377,7 +398,7 @@ type AirtableInventoryOptions = {
|
|
|
377
398
|
declare function fromAirtableBase(base: AirtableBase, opts?: AirtableInventoryOptions): InventoryProvider;
|
|
378
399
|
|
|
379
400
|
type Frequency = "None" | "Monthly" | "Quarterly" | "Yearly";
|
|
380
|
-
type Status = "in development" | "launch period" | "maintenance" | "hosting" | "probably not our problem" | "deprecated";
|
|
401
|
+
type Status = "in development" | "launch period" | "maintenance" | "hosting" | "probably not our problem" | "deprecated" | "legacy";
|
|
381
402
|
/**
|
|
382
403
|
* Per-site notification routing. When present on a `maintenance` site, the form
|
|
383
404
|
* notification is addressed by the value of a submission field (`field`, read from
|
|
@@ -504,8 +525,15 @@ type WebsiteRow = {
|
|
|
504
525
|
* "ok"`. Single-select `pass`/`fail`; null = never ran. No per-site Prismic token or identity
|
|
505
526
|
* column is ever built — this rides `/health`. */
|
|
506
527
|
cmsReachable: "pass" | "fail" | null;
|
|
507
|
-
/**
|
|
508
|
-
*
|
|
528
|
+
/** From the same `/health` body's `forms.turnstile` boolean: does the deployed site report
|
|
529
|
+
* the Turnstile widget configured (`PUBLIC_TURNSTILE_SITE_KEY` set)? Single-select
|
|
530
|
+
* `pass`/`fail`; null = never ran / older site package whose `/health` has no `forms`
|
|
531
|
+
* block. Freshness gated by `functionHealthCheckedAt`. Powers the cockpit Require-Turnstile
|
|
532
|
+
* guardrail: a site with `requireTurnstile` ON but no widget silently buckets 100% of its
|
|
533
|
+
* real leads (and form-e2e can't see it — testMode bypasses the gate). */
|
|
534
|
+
turnstileWidget: "pass" | "fail" | null;
|
|
535
|
+
/** When the `function-health` audit last ran — the freshness gate for `functionHealth`,
|
|
536
|
+
* `cmsReachable`, and `turnstileWidget`. Null = never ran. */
|
|
509
537
|
functionHealthCheckedAt: string | null;
|
|
510
538
|
/** Deployed-URL browser probe (the `browser` audit): cross-engine render OK, mobile render OK,
|
|
511
539
|
* internal-links OK + broken count, and when it last ran (one timestamp gates all three). */
|
|
@@ -534,9 +562,16 @@ type WebsiteRow = {
|
|
|
534
562
|
* blank → skipped. The API key is `key-dc` format; dc is derived from it. */
|
|
535
563
|
mailchimpApiKey: string | null;
|
|
536
564
|
mailchimpAudienceId: string | null;
|
|
537
|
-
/** Per-site Cloudflare Turnstile gate. When true, a submission
|
|
538
|
-
* token verifies as "fail"
|
|
539
|
-
*
|
|
565
|
+
/** Per-site Cloudflare Turnstile gate (Airtable checkbox). When true, a submission
|
|
566
|
+
* whose Turnstile token verifies as "fail" (forged) OR "absent" (secret configured
|
|
567
|
+
* centrally but NO token forwarded — the direct-POST-bot signature) is escalated to
|
|
568
|
+
* auto-spam regardless of content score. A present-but-expired token stays
|
|
569
|
+
* "unverifiable" and neutral (a real browser DID render the widget — fail-open).
|
|
570
|
+
* ROLLOUT PRECONDITION: only enable on a site whose DEPLOYED package forwards
|
|
571
|
+
* `_meta.turnstileToken` from EVERY form (widget rendered + `cf-turnstile-response`
|
|
572
|
+
* posted; check the site's `/health` → forms.turnstile:true). A site that never
|
|
573
|
+
* forwards tokens would silently bucket 100% of its real leads — and the form-e2e
|
|
574
|
+
* probe cannot catch that state (testMode bypasses the gate). */
|
|
540
575
|
requireTurnstile: boolean;
|
|
541
576
|
/** GitHub-signals sweep (slice 2a), written nightly by `github-signals --fleet`. */
|
|
542
577
|
renovateFailingCis: number | null;
|
|
@@ -752,6 +787,19 @@ type DraftResult = {
|
|
|
752
787
|
html: string;
|
|
753
788
|
/** Enrichment fetches that errored for this site (empty on success or skip). */
|
|
754
789
|
softFailures: SoftFailure[];
|
|
790
|
+
/** True when search enrichment fell back to the site name (no explicit `Search query`)
|
|
791
|
+
* AND that default matched nothing in Search Console — the signal that this site needs
|
|
792
|
+
* a hand-tuned brand query. Distinct from a soft-failure/outage: the fetch succeeded, it
|
|
793
|
+
* just found no data for the name. Requires a property to have RESOLVED — a site with no
|
|
794
|
+
* Search Console property at all raises `searchPropertyMissing` instead (a query change
|
|
795
|
+
* can't fix that, and prescribing one would silence the real problem). */
|
|
796
|
+
searchDefaultMissed: boolean;
|
|
797
|
+
/** True when NO Search Console property matched the site for any subject — a
|
|
798
|
+
* missing/unverified property or lost service-account access. Fires for BOTH explicit
|
|
799
|
+
* and name-default queries (unlike `searchDefaultMissed`), because no query can succeed
|
|
800
|
+
* without a property. Distinct from a soft-failure: the API worked, the property list
|
|
801
|
+
* just contained nothing for this site. */
|
|
802
|
+
searchPropertyMissing: boolean;
|
|
755
803
|
/** Whether the draft was placed in the approve queue. False when a higher-or-equal-tier
|
|
756
804
|
* report is already queued for the site (single-queue rule); null on the previewOnly path. */
|
|
757
805
|
queued: boolean | null;
|
|
@@ -878,7 +926,7 @@ declare function selfCaretRange(callerImportMetaUrl: string): string;
|
|
|
878
926
|
|
|
879
927
|
declare const SUBMISSION_STATUSES: readonly ["new", "read", "archived", "spam", "spam_auto"];
|
|
880
928
|
type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number];
|
|
881
|
-
declare const NOTIFY_STATUSES: readonly ["sent", "failed", "skipped"];
|
|
929
|
+
declare const NOTIFY_STATUSES: readonly ["sent", "failed", "skipped", "bounced"];
|
|
882
930
|
type NotifyStatus = (typeof NOTIFY_STATUSES)[number];
|
|
883
931
|
type SubmissionRow = {
|
|
884
932
|
id: string;
|
|
@@ -909,14 +957,6 @@ type ScreenOutTotals = {
|
|
|
909
957
|
markedSpam: number;
|
|
910
958
|
};
|
|
911
959
|
|
|
912
|
-
/**
|
|
913
|
-
* Render the per-site dashboard as a single HTML document. Pure function:
|
|
914
|
-
* no Airtable access, no env reads, no I/O. The Netlify function handler
|
|
915
|
-
* fetches data, then hands it here. Easier to unit-test, easier to render
|
|
916
|
-
* a static preview from CLI later.
|
|
917
|
-
*/
|
|
918
|
-
declare function renderSiteDashboardHtml(site: WebsiteRow, reports: ReportRow[], submissions?: SubmissionRow[], spamTotals?: ScreenOutTotals | null, now?: Date): string;
|
|
919
|
-
|
|
920
960
|
/** Severity of a "Needs attention" entry. `critical` sorts above `warning`. */
|
|
921
961
|
type AttentionSeverity = "critical" | "warning";
|
|
922
962
|
/** Set by `diffAttention` before render: how this item changed since the prior digest. */
|
|
@@ -930,7 +970,7 @@ type AttentionStatus = "new" | "worse" | "standing";
|
|
|
930
970
|
type AttentionItem = {
|
|
931
971
|
/** Stable identity for diffing: `vuln:<siteId>`, `delivery:<reportId>`. */
|
|
932
972
|
key: string;
|
|
933
|
-
kind: "vuln" | "delivery" | "renovate" | "lighthouse" | "ci" | "analytics" | "preflight";
|
|
973
|
+
kind: "vuln" | "delivery" | "renovate" | "lighthouse" | "ci" | "analytics" | "preflight" | "turnstile" | "notify-bounce";
|
|
934
974
|
/** Grouping key in the (component-3) render. */
|
|
935
975
|
siteName: string;
|
|
936
976
|
title: string;
|
|
@@ -957,6 +997,10 @@ type SiteCard = {
|
|
|
957
997
|
items: AttentionItem[];
|
|
958
998
|
/** Why the site is on Watch — human labels (empty unless tier === "watch"). */
|
|
959
999
|
watchReasons: string[];
|
|
1000
|
+
/** Primary accept token per watch reason, index-aligned with `watchReasons` — the
|
|
1001
|
+
* exact string the operator can add to Accepted Watch Conditions to mute it. Optional
|
|
1002
|
+
* for back-compat with hand-built card fixtures; the renderer falls back to no hint. */
|
|
1003
|
+
watchAcceptKeys?: string[];
|
|
960
1004
|
/** Structured watch tags ("lighthouse" / "stale") for the client filter. */
|
|
961
1005
|
watchSignals: string[];
|
|
962
1006
|
/** Watch reasons the operator has accepted: suppressed from the band, shown as a
|
|
@@ -964,6 +1008,10 @@ type SiteCard = {
|
|
|
964
1008
|
acceptedReasons: string[];
|
|
965
1009
|
/** Count of NEW submissions for this site (optional; populated by buildCockpitModel). */
|
|
966
1010
|
newSubmissions?: number;
|
|
1011
|
+
/** Split of newSubmissions: actionable leads vs newsletter/rsvp signups (2026-07-16).
|
|
1012
|
+
* Optional for back-compat with hand-built card fixtures. */
|
|
1013
|
+
newLeads?: number;
|
|
1014
|
+
newSignups?: number;
|
|
967
1015
|
};
|
|
968
1016
|
type PendingEntry = {
|
|
969
1017
|
reportId: string;
|
|
@@ -985,7 +1033,9 @@ type CockpitSummary = {
|
|
|
985
1033
|
attention: number;
|
|
986
1034
|
watch: number;
|
|
987
1035
|
healthy: number;
|
|
988
|
-
/** Count of pre-live "launch period" sites
|
|
1036
|
+
/** Count of pre-live "launch period" sites still muted as expected pre-launch
|
|
1037
|
+
* conditions. A piercing alarm (piercesPreLaunchMute) re-tiers such a site to
|
|
1038
|
+
* attention, so it counts there instead — muted never means "hidden if broken". */
|
|
989
1039
|
preLaunch: number;
|
|
990
1040
|
criticalHighVulns: number;
|
|
991
1041
|
lighthouseBelowFloor: number;
|
|
@@ -997,6 +1047,10 @@ type CockpitSummary = {
|
|
|
997
1047
|
pending: number;
|
|
998
1048
|
/** Count of NEW submissions across the fleet (optional for back-compat). */
|
|
999
1049
|
newSubmissions?: number;
|
|
1050
|
+
/** Split of newSubmissions: actionable leads vs newsletter/rsvp signups
|
|
1051
|
+
* (optional for back-compat, 2026-07-16). */
|
|
1052
|
+
newLeads?: number;
|
|
1053
|
+
newSignups?: number;
|
|
1000
1054
|
};
|
|
1001
1055
|
/** A render-ready row for the cockpit "Recently" lane. `url` is an external link
|
|
1002
1056
|
* (PR) when present; `slug` links to `/s/<slug>` when the event names a site. */
|
|
@@ -1008,6 +1062,13 @@ type RecentEntry = {
|
|
|
1008
1062
|
url: string | null;
|
|
1009
1063
|
ts: string;
|
|
1010
1064
|
};
|
|
1065
|
+
/** A Websites row surfaced OUTSIDE the fleet cards: archived (legacy/deprecated)
|
|
1066
|
+
* or holding an unrecognized Status cell. `status` is the raw cell value. */
|
|
1067
|
+
type OffFleetSiteEntry = {
|
|
1068
|
+
name: string;
|
|
1069
|
+
slug: string;
|
|
1070
|
+
status: string;
|
|
1071
|
+
};
|
|
1011
1072
|
type CockpitModel = {
|
|
1012
1073
|
summary: CockpitSummary;
|
|
1013
1074
|
/** All visible sites, ordered: attention (worst-first) → watch (A-Z) → healthy (A-Z). */
|
|
@@ -1025,7 +1086,31 @@ type CockpitModel = {
|
|
|
1025
1086
|
/** Fleet-wide count of submissions auto-filtered as spam in the affordance window
|
|
1026
1087
|
* (optional for back-compat). Drives the cockpit "N auto-filtered this week" line. */
|
|
1027
1088
|
autoFiltered?: number;
|
|
1089
|
+
/** Archived (legacy/deprecated) rows — excluded from every fleet op, listed on a
|
|
1090
|
+
* neutral collapsed lane so a row can never silently vanish (optional for back-compat). */
|
|
1091
|
+
archived?: OffFleetSiteEntry[];
|
|
1092
|
+
/** Rows whose Status cell is outside the code's union (typo / renamed option) —
|
|
1093
|
+
* invisible to every fleet op, so the Needs-you feed surfaces them as amber watch
|
|
1094
|
+
* rows (optional for back-compat). */
|
|
1095
|
+
unrecognizedStatus?: OffFleetSiteEntry[];
|
|
1028
1096
|
};
|
|
1097
|
+
/** The cockpit's alarm verdict for ONE site, for the /s/<slug> page header. */
|
|
1098
|
+
type SiteAlarmContext = {
|
|
1099
|
+
tier: Tier;
|
|
1100
|
+
/** This site's attention items, severity-sorted, status UNTAGGED (no diffAttention). */
|
|
1101
|
+
items: AttentionItem[];
|
|
1102
|
+
watchReasons: string[];
|
|
1103
|
+
watchAcceptKeys: string[];
|
|
1104
|
+
acceptedReasons: string[];
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Render the per-site dashboard as a single HTML document. Pure function:
|
|
1109
|
+
* no Airtable access, no env reads, no I/O. The Netlify function handler
|
|
1110
|
+
* fetches data, then hands it here. Easier to unit-test, easier to render
|
|
1111
|
+
* a static preview from CLI later.
|
|
1112
|
+
*/
|
|
1113
|
+
declare function renderSiteDashboardHtml(site: WebsiteRow, reports: ReportRow[], submissions?: SubmissionRow[], spamTotals?: ScreenOutTotals | null, now?: Date, alarm?: SiteAlarmContext | null): string;
|
|
1029
1114
|
|
|
1030
1115
|
/**
|
|
1031
1116
|
* Render the fleet cockpit as a single HTML document. Pure function: no Airtable
|