@reddoorla/maintenance 0.88.2 → 0.88.3
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/{blux-DN4MI532.js → blux-EATHOHKK.js} +5 -5
- package/dist/{chunk-KMCZA7CH.js → chunk-6UCVWYDA.js} +4 -4
- package/dist/{chunk-7TMWBXX7.js → chunk-CEW5PEHZ.js} +10 -3
- package/dist/{chunk-7TMWBXX7.js.map → chunk-CEW5PEHZ.js.map} +1 -1
- package/dist/{chunk-ANJNTAMM.js → chunk-WPL5GERE.js} +3 -1
- package/dist/{chunk-A2UNJIKH.js → chunk-XKDCR4UE.js} +6 -3
- package/dist/chunk-XKDCR4UE.js.map +1 -0
- package/dist/{chunk-K6SLF6GI.js → chunk-XSOINOPY.js} +4 -4
- package/dist/cli/bin.js +13 -10
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +2 -2
- package/dist/{db-V6UIS433.js → db-DXUUAMXT.js} +2 -2
- package/dist/{digest-2JEAEJK6.js → digest-NBLECFM7.js} +65 -8
- package/dist/digest-NBLECFM7.js.map +1 -0
- package/dist/digest-collectors-NM7WUWQP.js +50 -0
- package/dist/digest-state-3KHZF7GD.js +50 -0
- package/dist/digest-state-3KHZF7GD.js.map +1 -0
- package/dist/{email-SL7464YI.js → email-FMMEUPX6.js} +3 -3
- package/dist/{github-signals-GWGCKEE2.js → github-signals-DB25UWY4.js} +3 -3
- package/dist/index.d.ts +3 -0
- package/dist/index.js +12 -11
- package/dist/index.js.map +1 -1
- package/dist/{init-FGTYNGUI.js → init-32AWPS7A.js} +3 -3
- package/dist/{launch-5H7LPEGP.js → launch-52WUE4TZ.js} +3 -3
- package/dist/{pipeline-WYTXSLHX.js → pipeline-YIBIQ7XK.js} +2 -2
- package/dist/{prismic-models-NJN5JORF.js → prismic-models-IJWZW2EO.js} +2 -2
- package/dist/prospect/types.d.ts +19 -0
- package/dist/{prospect-audit-JMBRVXSG.js → prospect-audit-WJQI7V5V.js} +4 -4
- package/dist/{render-VX72AESY.js → render-CVKE7IWC.js} +3 -3
- package/dist/{report-TALY5P3L.js → report-2LM2SX67.js} +5 -5
- package/dist/{selftest-NZTR4SM2.js → selftest-DIZ5JD2X.js} +2 -2
- package/dist/{write-audits-to-airtable-OBHGR7DQ.js → write-audits-to-airtable-OPBJXGS4.js} +3 -3
- package/dist/write-audits-to-airtable-OPBJXGS4.js.map +1 -0
- package/package.json +1 -1
- package/dist/chunk-A2UNJIKH.js.map +0 -1
- package/dist/digest-2JEAEJK6.js.map +0 -1
- package/dist/digest-state-CRZRNGDI.js +0 -25
- package/dist/digest-state-CRZRNGDI.js.map +0 -1
- /package/dist/{blux-DN4MI532.js.map → blux-EATHOHKK.js.map} +0 -0
- /package/dist/{chunk-KMCZA7CH.js.map → chunk-6UCVWYDA.js.map} +0 -0
- /package/dist/{chunk-ANJNTAMM.js.map → chunk-WPL5GERE.js.map} +0 -0
- /package/dist/{chunk-K6SLF6GI.js.map → chunk-XSOINOPY.js.map} +0 -0
- /package/dist/{db-V6UIS433.js.map → db-DXUUAMXT.js.map} +0 -0
- /package/dist/{pipeline-WYTXSLHX.js.map → digest-collectors-NM7WUWQP.js.map} +0 -0
- /package/dist/{email-SL7464YI.js.map → email-FMMEUPX6.js.map} +0 -0
- /package/dist/{github-signals-GWGCKEE2.js.map → github-signals-DB25UWY4.js.map} +0 -0
- /package/dist/{init-FGTYNGUI.js.map → init-32AWPS7A.js.map} +0 -0
- /package/dist/{launch-5H7LPEGP.js.map → launch-52WUE4TZ.js.map} +0 -0
- /package/dist/{render-VX72AESY.js.map → pipeline-YIBIQ7XK.js.map} +0 -0
- /package/dist/{prismic-models-NJN5JORF.js.map → prismic-models-IJWZW2EO.js.map} +0 -0
- /package/dist/{prospect-audit-JMBRVXSG.js.map → prospect-audit-WJQI7V5V.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-OBHGR7DQ.js.map → render-CVKE7IWC.js.map} +0 -0
- /package/dist/{report-TALY5P3L.js.map → report-2LM2SX67.js.map} +0 -0
- /package/dist/{selftest-NZTR4SM2.js.map → selftest-DIZ5JD2X.js.map} +0 -0
|
@@ -8,14 +8,14 @@ import {
|
|
|
8
8
|
} from "./chunk-Q47DU255.js";
|
|
9
9
|
import {
|
|
10
10
|
init
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-XSOINOPY.js";
|
|
12
|
+
import "./chunk-XIDUYLSR.js";
|
|
12
13
|
import "./chunk-IYSEC543.js";
|
|
13
14
|
import "./chunk-BQRFQSGY.js";
|
|
14
15
|
import "./chunk-Y53WOQIJ.js";
|
|
15
16
|
import "./chunk-7RJ2HMMJ.js";
|
|
16
17
|
import "./chunk-R53VL3RI.js";
|
|
17
18
|
import "./chunk-J7SZQUCW.js";
|
|
18
|
-
import "./chunk-XIDUYLSR.js";
|
|
19
19
|
import "./chunk-7UAYMAKR.js";
|
|
20
20
|
import "./chunk-73UFRZKY.js";
|
|
21
21
|
import "./chunk-3G25KIWW.js";
|
|
@@ -110,4 +110,4 @@ async function runInitCommand(site, opts) {
|
|
|
110
110
|
export {
|
|
111
111
|
runInitCommand
|
|
112
112
|
};
|
|
113
|
-
//# sourceMappingURL=init-
|
|
113
|
+
//# sourceMappingURL=init-32AWPS7A.js.map
|
|
@@ -4,12 +4,12 @@ import {
|
|
|
4
4
|
import "./chunk-MNDIED4M.js";
|
|
5
5
|
import {
|
|
6
6
|
writeAuditsToAirtable
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-6UCVWYDA.js";
|
|
8
|
+
import "./chunk-RUPPWAD6.js";
|
|
8
9
|
import {
|
|
9
10
|
hasRealScores,
|
|
10
11
|
lighthouseScoresFromResult
|
|
11
12
|
} from "./chunk-7BZUOZPU.js";
|
|
12
|
-
import "./chunk-RUPPWAD6.js";
|
|
13
13
|
import {
|
|
14
14
|
resolveSites
|
|
15
15
|
} from "./chunk-Q47DU255.js";
|
|
@@ -252,4 +252,4 @@ async function runLaunchCommand(site, opts) {
|
|
|
252
252
|
export {
|
|
253
253
|
runLaunchCommand
|
|
254
254
|
};
|
|
255
|
-
//# sourceMappingURL=launch-
|
|
255
|
+
//# sourceMappingURL=launch-52WUE4TZ.js.map
|
|
@@ -2,11 +2,11 @@ import {
|
|
|
2
2
|
ANALYZE_SKIPPED,
|
|
3
3
|
PROBES_SKIPPED,
|
|
4
4
|
runProspectAudit
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-CEW5PEHZ.js";
|
|
6
6
|
import "./chunk-RARREDQE.js";
|
|
7
7
|
export {
|
|
8
8
|
ANALYZE_SKIPPED,
|
|
9
9
|
PROBES_SKIPPED,
|
|
10
10
|
runProspectAudit
|
|
11
11
|
};
|
|
12
|
-
//# sourceMappingURL=pipeline-
|
|
12
|
+
//# sourceMappingURL=pipeline-YIBIQ7XK.js.map
|
|
@@ -769,7 +769,7 @@ async function persistSweep(rows, deps) {
|
|
|
769
769
|
let sink;
|
|
770
770
|
let formatSummary;
|
|
771
771
|
try {
|
|
772
|
-
formatSummary = (await import("./write-audits-to-airtable-
|
|
772
|
+
formatSummary = (await import("./write-audits-to-airtable-OPBJXGS4.js")).formatFleetWriteSummary;
|
|
773
773
|
sink = await deps.openVerdictSink();
|
|
774
774
|
} catch (e) {
|
|
775
775
|
return {
|
|
@@ -1022,4 +1022,4 @@ export {
|
|
|
1022
1022
|
sweepRowWriteback,
|
|
1023
1023
|
writeSweepToAirtable
|
|
1024
1024
|
};
|
|
1025
|
-
//# sourceMappingURL=prismic-models-
|
|
1025
|
+
//# sourceMappingURL=prismic-models-IJWZW2EO.js.map
|
package/dist/prospect/types.d.ts
CHANGED
|
@@ -235,6 +235,25 @@ type ProbesResult = {
|
|
|
235
235
|
domain: string;
|
|
236
236
|
count: number;
|
|
237
237
|
}[];
|
|
238
|
+
/** How many CATEGORY probes were sent versus how many came back.
|
|
239
|
+
*
|
|
240
|
+
* These differ whenever an engine errors: a probe that fails after its retry
|
|
241
|
+
* is skipped, and before this existed it vanished from the denominator too —
|
|
242
|
+
* so the score was computed over the survivors. Ask five, have three fail,
|
|
243
|
+
* and one of the two survivors names you, and the report read "named in 1 of
|
|
244
|
+
* 2 searches" and scored 50 where the truth was 1 of 5. A flakier run scored
|
|
245
|
+
* HIGHER, nothing recorded that three probes died, and no two runs were
|
|
246
|
+
* comparable. `attempted` is now the divisor and both numbers are reported so
|
|
247
|
+
* a degraded run can say so.
|
|
248
|
+
*
|
|
249
|
+
* Optional because this type also describes runs deserialized from
|
|
250
|
+
* `prospect_audits.result_json`, and every report stored before this field
|
|
251
|
+
* existed lacks it. `runVisibilityProbes` always sets it; readers must still
|
|
252
|
+
* handle its absence rather than assume a stored report has it. */
|
|
253
|
+
categoryProbes?: {
|
|
254
|
+
attempted: number;
|
|
255
|
+
answered: number;
|
|
256
|
+
};
|
|
238
257
|
};
|
|
239
258
|
type LighthouseScores = {
|
|
240
259
|
performance: number | null;
|
|
@@ -95,7 +95,7 @@ async function runProspectAuditCommand(url, opts) {
|
|
|
95
95
|
"No TURSO_DATABASE_URL, so the report cannot be saved or shared. Re-run with --out <file>, or set the Turso credentials."
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
|
-
const { runProspectAudit } = await import("./pipeline-
|
|
98
|
+
const { runProspectAudit } = await import("./pipeline-YIBIQ7XK.js");
|
|
99
99
|
const onStage = (name, status, detail) => {
|
|
100
100
|
if (status === "start") console.error(`\u2026 ${name}`);
|
|
101
101
|
else if (status === "ok") console.error(`\u2713 ${name}`);
|
|
@@ -113,7 +113,7 @@ async function runProspectAuditCommand(url, opts) {
|
|
|
113
113
|
},
|
|
114
114
|
{ ...opts.deps ?? {}, onStage }
|
|
115
115
|
);
|
|
116
|
-
const { renderProspectReport } = await import("./render-
|
|
116
|
+
const { renderProspectReport } = await import("./render-CVKE7IWC.js");
|
|
117
117
|
const html = renderProspectReport(result);
|
|
118
118
|
const warnings = [];
|
|
119
119
|
let file = null;
|
|
@@ -165,7 +165,7 @@ async function runProspectAuditCommand(url, opts) {
|
|
|
165
165
|
let email = null;
|
|
166
166
|
if (opts.email) {
|
|
167
167
|
try {
|
|
168
|
-
const { sendAuditEmail, parseProspectAuditRecipients } = await import("./email-
|
|
168
|
+
const { sendAuditEmail, parseProspectAuditRecipients } = await import("./email-FMMEUPX6.js");
|
|
169
169
|
const recipients = parseProspectAuditRecipients(process.env.PROSPECT_AUDIT_RECIPIENTS);
|
|
170
170
|
email = await sendAuditEmail(result, { link, recipients, auditId, pdf });
|
|
171
171
|
if (!email.sent) {
|
|
@@ -190,4 +190,4 @@ async function runProspectAuditCommand(url, opts) {
|
|
|
190
190
|
export {
|
|
191
191
|
runProspectAuditCommand
|
|
192
192
|
};
|
|
193
|
-
//# sourceMappingURL=prospect-audit-
|
|
193
|
+
//# sourceMappingURL=prospect-audit-WJQI7V5V.js.map
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
renderProspectReport
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
3
|
+
} from "./chunk-XKDCR4UE.js";
|
|
4
|
+
import "./chunk-CEW5PEHZ.js";
|
|
5
5
|
import "./chunk-BGTYPVLT.js";
|
|
6
6
|
import "./chunk-RARREDQE.js";
|
|
7
7
|
export {
|
|
8
8
|
renderProspectReport
|
|
9
9
|
};
|
|
10
|
-
//# sourceMappingURL=render-
|
|
10
|
+
//# sourceMappingURL=render-CVKE7IWC.js.map
|
|
@@ -5,14 +5,14 @@ import {
|
|
|
5
5
|
draftReportForSite,
|
|
6
6
|
readGaConfig
|
|
7
7
|
} from "./chunk-J3WEZGIN.js";
|
|
8
|
+
import {
|
|
9
|
+
reportTier
|
|
10
|
+
} from "./chunk-YFAXOD7Z.js";
|
|
8
11
|
import {
|
|
9
12
|
findDueReports,
|
|
10
13
|
nextDueDate,
|
|
11
14
|
reportPeriodKey
|
|
12
15
|
} from "./chunk-PZR4EBPY.js";
|
|
13
|
-
import {
|
|
14
|
-
reportTier
|
|
15
|
-
} from "./chunk-YFAXOD7Z.js";
|
|
16
16
|
import {
|
|
17
17
|
listAllReports
|
|
18
18
|
} from "./chunk-F3JWOPTG.js";
|
|
@@ -78,7 +78,7 @@ function dashboardBaseUrl() {
|
|
|
78
78
|
}
|
|
79
79
|
async function runReportCommand(slug, opts) {
|
|
80
80
|
if (opts.digest) {
|
|
81
|
-
const { runDigest } = await import("./digest-
|
|
81
|
+
const { runDigest } = await import("./digest-NBLECFM7.js");
|
|
82
82
|
return runDigest({ baseUrl: dashboardBaseUrl() });
|
|
83
83
|
}
|
|
84
84
|
if (opts.sendReady) {
|
|
@@ -350,4 +350,4 @@ export {
|
|
|
350
350
|
runReportCommand,
|
|
351
351
|
writeNextDueDates
|
|
352
352
|
};
|
|
353
|
-
//# sourceMappingURL=report-
|
|
353
|
+
//# sourceMappingURL=report-2LM2SX67.js.map
|
|
@@ -6,10 +6,10 @@ import {
|
|
|
6
6
|
operatorEmail
|
|
7
7
|
} from "./chunk-5ML7FMBD.js";
|
|
8
8
|
import "./chunk-J3WEZGIN.js";
|
|
9
|
+
import "./chunk-YFAXOD7Z.js";
|
|
9
10
|
import {
|
|
10
11
|
ELIGIBLE_STATUSES
|
|
11
12
|
} from "./chunk-PZR4EBPY.js";
|
|
12
|
-
import "./chunk-YFAXOD7Z.js";
|
|
13
13
|
import {
|
|
14
14
|
isProbablyEmail,
|
|
15
15
|
parseAddresses
|
|
@@ -172,4 +172,4 @@ async function runSelftestCommand(kind, site, opts) {
|
|
|
172
172
|
export {
|
|
173
173
|
runSelftestCommand
|
|
174
174
|
};
|
|
175
|
-
//# sourceMappingURL=selftest-
|
|
175
|
+
//# sourceMappingURL=selftest-DIZ5JD2X.js.map
|
|
@@ -3,9 +3,9 @@ import {
|
|
|
3
3
|
formatFleetWriteSummary,
|
|
4
4
|
writeAuditsToAirtable,
|
|
5
5
|
writeFleetAuditsToAirtable
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
import "./chunk-7BZUOZPU.js";
|
|
6
|
+
} from "./chunk-6UCVWYDA.js";
|
|
8
7
|
import "./chunk-RUPPWAD6.js";
|
|
8
|
+
import "./chunk-7BZUOZPU.js";
|
|
9
9
|
import "./chunk-5L3FPQIJ.js";
|
|
10
10
|
import "./chunk-7NYN2P6I.js";
|
|
11
11
|
export {
|
|
@@ -14,4 +14,4 @@ export {
|
|
|
14
14
|
writeAuditsToAirtable,
|
|
15
15
|
writeFleetAuditsToAirtable
|
|
16
16
|
};
|
|
17
|
-
//# sourceMappingURL=write-audits-to-airtable-
|
|
17
|
+
//# sourceMappingURL=write-audits-to-airtable-OPBJXGS4.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/prospect/render.ts"],"sourcesContent":["import { escapeHtml, safeUrl } from \"../util/html.js\";\nimport { hostnameOf } from \"../util/url.js\";\nimport { ANALYZE_SKIPPED, PROBES_SKIPPED } from \"./pipeline.js\";\nimport { resolveBusinessName } from \"./probes.js\";\nimport type {\n AnalyzeResult,\n ChecksResult,\n Fix,\n LighthouseScores,\n ProbeAnswer,\n ProbesResult,\n ProspectAuditResult,\n StageResult,\n} from \"./types.js\";\n\nconst RED = \"#d71920\";\nconst IMPACT_ORDER: Record<Fix[\"impact\"], number> = { high: 0, medium: 1, low: 2 };\nconst KIND_LABEL: Record<ProbeAnswer[\"kind\"], string> = {\n branded: \"Branded — asked about the business by name\",\n category: \"Category — the questions a buyer would actually type\",\n competitor: \"Competitor — head-to-head comparisons\",\n};\nconst KIND_ORDER: ProbeAnswer[\"kind\"][] = [\"category\", \"branded\", \"competitor\"];\n\n/** Terse product mapping so a recipient who has never seen a crawler's raw\n * user-agent string can place it — factual labelling, not a voice change. */\nconst AI_AGENT_LABELS: Record<string, string> = {\n GPTBot: \"ChatGPT\",\n \"OAI-SearchBot\": \"ChatGPT's search results\",\n ClaudeBot: \"Claude\",\n PerplexityBot: \"Perplexity\",\n \"Google-Extended\": \"Gemini and Google's AI Overviews\",\n CCBot: \"Common Crawl, used to train many AI models\",\n};\n\nfunction describeAgent(agent: string): string {\n const label = AI_AGENT_LABELS[agent];\n return label ? `${agent} (feeds ${label})` : agent;\n}\n\n/** Every genuine stage failure collapses to this one phrase — the real error\n * (status codes, retry counts, timeouts — operator vocabulary) stays in the\n * persisted JSON and CLI output for operators, but never reaches a stranger\n * who would read it as broken software rather than a diagnostic. */\nconst STAGE_FAILED_MESSAGE = \"we could not complete this part of the analysis\";\n\nconst STYLES = `\n :root { color-scheme: light; }\n * { box-sizing: border-box; }\n body { margin: 0; background: #faf8f5; color: #1a1a1a;\n font: 16px/1.6 -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif; }\n .wrap { max-width: 860px; margin: 0 auto; padding: 48px 24px 80px; }\n h1, h2, h3 { font-family: Besley, Georgia, \"Times New Roman\", serif; font-weight: 600; line-height: 1.2; }\n h1 { font-size: 40px; margin: 0 0 8px; }\n h2 { font-size: 26px; margin: 48px 0 12px; border-top: 2px solid #e6e1da; padding-top: 24px; }\n h3 { font-size: 18px; margin: 24px 0 8px; }\n .lede { color: #57544f; margin: 0 0 8px; }\n .scores { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin: 32px 0; }\n .score { background: #fff; border: 1px solid #e6e1da; border-radius: 8px; padding: 16px; }\n .score .n { font-family: Besley, Georgia, serif; font-size: 38px; line-height: 1; color: ${RED}; }\n .score .n.na { font-size: 18px; color: #8a857e; }\n .score .l { font-size: 13px; letter-spacing: .08em; text-transform: uppercase; color: #57544f; margin-top: 8px; }\n .score .hint { font-size: 12px; color: #8a857e; margin-top: 4px; }\n .card { background: #fff; border: 1px solid #e6e1da; border-radius: 8px; padding: 16px; margin: 12px 0; }\n .q { font-weight: 600; }\n .tag { display: inline-block; font-size: 12px; letter-spacing: .06em; text-transform: uppercase;\n border-radius: 999px; padding: 2px 10px; border: 1px solid currentColor; }\n .yes { color: #14663c; } .partial { color: #8a6d00; } .no { color: ${RED}; }\n ul { padding-left: 20px; } li { margin: 6px 0; }\n .muted { color: #8a857e; }\n .cta { margin-top: 56px; background: ${RED}; color: #fff; border-radius: 8px; padding: 24px 28px; }\n .cta h2 { border: 0; padding: 0; margin: 0 0 8px; color: #fff; }\n .cta a { color: #fff; }\n table { width: 100%; border-collapse: collapse; margin-top: 8px; }\n td, th { text-align: left; padding: 6px 8px; border-bottom: 1px solid #eee; vertical-align: top; }\n @media print {\n body { background: #fff; }\n .card, .score, .cta { break-inside: avoid; }\n /* Browsers default \"print background graphics\" OFF, which drops the red\n .cta background but keeps its white text — the only ask in the report\n would otherwise print white-on-white and vanish. Fall back to dark\n text on no background with a visible border instead of relying on the\n background for contrast. */\n .cta { background: none; color: #1a1a1a; border: 2px solid ${RED}; }\n .cta h2, .cta a { color: #1a1a1a; }\n }\n`;\n\nfunction scoreCard(label: string, value: number | null, hint?: string): string {\n const n =\n value === null ? `<div class=\"n na\">Not measured</div>` : `<div class=\"n\">${value}</div>`;\n const hintHtml = hint ? `<div class=\"hint\">${escapeHtml(hint)}</div>` : \"\";\n return `<div class=\"score\">${n}<div class=\"l\">${escapeHtml(label)}</div>${hintHtml}</div>`;\n}\n\n/** Body for a stage that succeeded, or a uniform \"Not measured\" note when it didn't. */\nfunction stageBody<T>(stage: StageResult<T>, body: (data: T) => string): string {\n return stage.ok ? body(stage.data) : notMeasuredNote();\n}\n\n/** The stage's real error is deliberately not a parameter here — see\n * STAGE_FAILED_MESSAGE above. Every genuine failure reads the same\n * client-safe way, regardless of what actually broke. */\nfunction notMeasuredNote(): string {\n return `<p class=\"muted\">Not measured — ${STAGE_FAILED_MESSAGE}.</p>`;\n}\n\n/** A stage a human deliberately turned off (--no-probes, or analyze skipped\n * because checks failed upstream) must read as \"you asked us to skip this\" —\n * never as \"we tried and could not\", which is what a bare \"Not measured —\n * {error}\" would otherwise imply. Compares against the pipeline's exported\n * constants rather than a retyped string literal. */\nfunction skippableStageNote(error: string, skipConstant: string, skipMessage: string): string {\n if (error === skipConstant) {\n return `<p class=\"muted\">${escapeHtml(skipMessage)}</p>`;\n }\n return notMeasuredNote();\n}\n\n/** `new Date(iso).toLocaleDateString()` never throws on a bad timestamp — it\n * silently returns \"Invalid Date\" — so a try/catch here is dead code. Guard\n * on getTime() instead, and fall back to the raw ISO string so a corrupt\n * timestamp is at least visibly a timestamp rather than the literal words\n * \"Invalid Date\". */\nfunction formatIsoDate(iso: string): string {\n const d = new Date(iso);\n if (Number.isNaN(d.getTime())) return iso;\n return d.toLocaleDateString(\"en-US\", {\n year: \"numeric\",\n month: \"long\",\n day: \"numeric\",\n timeZone: \"UTC\",\n });\n}\n\n/** The probes section leads the report, so a branded-only run (analyze\n * failed upstream and supplied no buyer questions, or every category query\n * itself failed) must not let the branded recognition line read as a\n * positive discoverability finding on its own — an engine echoing a name\n * it was just handed proves nothing about whether a buyer who never named\n * the business would find it. */\n/** `businessNameUsed` is whether the engines were actually queried with the\n * business NAME — not merely whether one existed. `resolveBusinessName` falls\n * back to the bare domain for anything it cannot use, so a name can be present\n * and still never reach a query. Without this gate, \"even with the name handed\n * to them\" is a false claim: whenever businessName was empty, and (until the\n * caller was fixed) whenever the name was rejected and the probes searched for\n * the domain instead. Never derived from the raw ProbesResult, which has no\n * way to say \"no name was available\". */\nfunction buildProbesSection(p: ProbesResult, businessNameUsed: boolean): string {\n const byKind = new Map<ProbeAnswer[\"kind\"], ProbeAnswer[]>();\n for (const a of p.answers) {\n const bucket = byKind.get(a.kind) ?? [];\n bucket.push(a);\n byKind.set(a.kind, bucket);\n }\n\n const recognition = p.brandedRecognized\n ? \"<p>When the engines were asked about the business by name, they recognized it.</p>\"\n : businessNameUsed\n ? \"<p>When the engines were asked about the business by name, they did not recognize it — a real citation of the site never showed up, even with the name handed to them.</p>\"\n : \"<p>The engines did not recognize the business — there was no business name available to ask them with, so this reflects a harder starting point than a named query would.</p>\";\n\n const categoryCaveat = byKind.has(\"category\")\n ? \"\"\n : `<p class=\"muted\"><strong>No buyer-question (category) query was tested here</strong> — only name-recognition. An engine echoing back a name it was just given says nothing about whether a buyer who had never heard of the business would be shown it; treat the line above as a floor, not a visibility signal.</p>`;\n\n const groups = KIND_ORDER.filter((k) => byKind.has(k))\n .map((kind) => {\n const answers = byKind.get(kind) ?? [];\n const cards = answers\n .map(\n (a) => `<div class=\"card\">\n <div class=\"q\">${escapeHtml(a.engine)} · “${escapeHtml(a.query)}”</div>\n <p class=\"muted\">Asked ${escapeHtml(formatIsoDate(a.askedAt))}</p>\n <p>${escapeHtml(a.snippet)}${a.truncated ? \"…\" : \"\"}</p>\n <p class=\"muted\">${\n // The scorer's own decision, recorded on the answer — NOT\n // `domainCited || brandMentioned`, which is a looser rule. Using the\n // loose one printed \"You were named in this answer\" above a card\n // contributing zero to the score beside it. Reports persisted before\n // the field existed fall back to what they already said.\n (a.countedAsVisible ?? (a.domainCited || a.brandMentioned))\n ? \"You were named in this answer.\"\n : \"You were not named in this answer.\"\n }${a.citedDomains.length ? ` Sources the engine retrieved: ${escapeHtml(a.citedDomains.join(\", \"))}` : \"\"}</p>\n </div>`,\n )\n .join(\"\");\n return `<h3>${escapeHtml(KIND_LABEL[kind])}</h3>${cards}`;\n })\n .join(\"\");\n\n const competitors = p.competitorsSeen.length\n ? `<h3>Who the engines cited instead</h3><ul>${p.competitorsSeen\n .map((c) => `<li>${escapeHtml(c.domain)} — ${c.count} time${c.count === 1 ? \"\" : \"s\"}</li>`)\n .join(\"\")}</ul>`\n : \"\";\n\n return recognition + categoryCaveat + groups + competitors;\n}\n\n/** crawlerAccessMeasured is false only when the robots.txt fetch itself\n * failed, so the blocked/allowed lists are empty out of ignorance, not\n * because we confirmed access. Saying \"every crawler can reach the site\" in\n * that case would manufacture a finding from our own missing data.\n * sitemapMeasured/llmsTxtMeasured get the same treatment as\n * crawlerAccessMeasured: a failed fetch reads as \"not measured\", never as a\n * confirmed \"missing\" — that's a claim about the prospect's site we haven't\n * earned. Item 1: none of the three \"not measured\" lines below take the raw\n * fetch error as input anymore — status codes and transport vocabulary\n * (\"503 Service Unavailable\", \"ETIMEDOUT\") are operator vocabulary, same as\n * a stage failure (see STAGE_FAILED_MESSAGE); the real error still lives in\n * the persisted JSON and CLI output, never in this client-facing page. */\nfunction buildFindabilitySection(c: ChecksResult): string {\n const accessBlock = !c.crawlerAccessMeasured\n ? `<p class=\"muted\">Crawler access: not measured — ${STAGE_FAILED_MESSAGE}.</p>`\n : c.crawlerAccess.blockedAi.length\n ? `<p><strong>Blocked AI crawlers:</strong> ${escapeHtml(\n c.crawlerAccess.blockedAi.map(describeAgent).join(\", \"),\n )}</p>`\n : `<p>Every AI crawler we checked can reach the site.</p>`;\n const classical =\n c.crawlerAccessMeasured && c.crawlerAccess.blockedClassical.length\n ? `<p><strong>Blocked search crawlers:</strong> ${escapeHtml(\n c.crawlerAccess.blockedClassical.join(\", \"),\n )}</p>`\n : \"\";\n const sitemapLine = c.sitemapMeasured\n ? `<li>sitemap.xml: ${c.sitemapPresent ? \"present\" : \"missing\"}</li>`\n : `<li>sitemap.xml: not measured — ${STAGE_FAILED_MESSAGE}.</li>`;\n const llmsLine = c.llmsTxtMeasured\n ? `<li>llms.txt: ${c.llmsTxtPresent ? \"present\" : \"missing\"}</li>`\n : `<li>llms.txt: not measured — ${STAGE_FAILED_MESSAGE}.</li>`;\n return `${accessBlock}${classical}\n <ul>\n ${sitemapLine}\n ${llmsLine}\n <li>Pages missing a meta description: ${c.meta.missingDescription} of ${c.meta.pageCount}</li>\n <li>Pages missing a canonical URL: ${c.meta.missingCanonical} of ${c.meta.pageCount}</li>\n <li>Pages missing share images/titles: ${c.meta.missingSocial} of ${c.meta.pageCount}</li>\n <li>Security headers missing: ${\n c.securityHeaders.missing.length ? escapeHtml(c.securityHeaders.missing.join(\", \")) : \"none\"\n }</li>\n ${\n c.meta.pagesWithoutExtract > 0\n ? `<li><strong>${c.meta.pagesWithoutExtract}</strong> additional page${\n c.meta.pagesWithoutExtract === 1 ? \"\" : \"s\"\n } produced no readable content at all and ${\n c.meta.pagesWithoutExtract === 1 ? \"is\" : \"are\"\n } not counted in the ${c.meta.pageCount} pages above.</li>`\n : \"\"\n }\n </ul>`;\n}\n\n/** Lighthouse runs as its own independent pipeline stage (see pipeline.ts) —\n * it does not depend on `checks` succeeding. Rendering it off `result.checks`\n * would throw away real, measured performance/SEO/accessibility numbers\n * whenever an unrelated stage failed, so this reads `result.lighthouse`\n * directly and is rendered unconditionally, not nested inside the checks\n * degrade path. */\nfunction buildLighthouseBlock(lh: StageResult<LighthouseScores>): string {\n return lh.ok\n ? `<p class=\"muted\">Lighthouse — performance ${lh.data.performance ?? \"not measured\"},\n SEO ${lh.data.seo ?? \"not measured\"}, accessibility ${lh.data.accessibility ?? \"not measured\"}.</p>`\n : `<p class=\"muted\">Lighthouse not measured — ${STAGE_FAILED_MESSAGE}.</p>`;\n}\n\nfunction buildReadabilitySection(c: ChecksResult): string {\n const jsLine =\n c.jsDependence.avgMissing === null\n ? `<p class=\"muted\">JavaScript-dependence: not measured — no page produced a comparable raw/rendered pair.</p>`\n : `<p><strong>${Math.round(c.jsDependence.avgMissing * 100)}%</strong> of the words a visitor reads only appear after JavaScript runs.\n Most AI crawlers do not run JavaScript, so that share of your site is invisible to them.</p>`;\n return `${jsLine}\n <ul>\n <li>Structured data found: ${c.schema.typesFound.length ? escapeHtml(c.schema.typesFound.join(\", \")) : \"none\"}</li>\n <li>Expected structured data missing: ${\n c.schema.missingExpected.length ? escapeHtml(c.schema.missingExpected.join(\", \")) : \"none\"\n }</li>\n <li>Pages without a top-level heading: ${c.headings.pagesWithoutH1} of ${c.meta.pageCount}</li>\n ${\n c.meta.pagesWithoutExtract > 0\n ? `<li><strong>${c.meta.pagesWithoutExtract}</strong> page${\n c.meta.pagesWithoutExtract === 1 ? \"\" : \"s\"\n } produced no extract at all — excluded from every figure above, not counted as readable or unreadable.</li>`\n : \"\"\n }\n </ul>`;\n}\n\n/** The report's whole premise is claims the recipient can go check — so the\n * one quoted passage of evidence links to the page it came from (via\n * safeUrl; a null/non-http page renders as plain escaped text, no dead\n * \"#\" anchor). `answered`/`impact`/`effort` are escaped even though a zod\n * enum constrains them upstream — the renderer should not depend on\n * another file staying in sync to stay safe. */\nfunction buildAnswersSection(a: AnalyzeResult): string {\n const rows = a.buyerQuestions\n .map((q) => {\n const answered = escapeHtml(q.answered);\n const evidenceText = q.evidence ? escapeHtml(q.evidence) : null;\n const evidenceHref = q.evidence && q.page ? safeUrl(q.page) : null;\n const evidenceCell =\n evidenceText === null\n ? '<span class=\"muted\">no passage on the site</span>'\n : evidenceHref && evidenceHref !== \"#\"\n ? `<a href=\"${escapeHtml(evidenceHref)}\" target=\"_blank\" rel=\"noopener noreferrer\">${evidenceText}</a>`\n : evidenceText;\n return `<tr>\n <td>${escapeHtml(q.question)}</td>\n <td><span class=\"tag ${answered}\">${answered}</span></td>\n <td>${evidenceCell}</td>\n </tr>`;\n })\n .join(\"\");\n return `<p>${escapeHtml(a.narrative.answers)}</p>\n <table><tr><th>What buyers ask</th><th>Answered</th><th>Evidence</th></tr>${rows}</table>`;\n}\n\nexport function renderProspectReport(result: ProspectAuditResult): string {\n const host = hostnameOf(result.url);\n // `businessName` is the searchable NAME (possibly \"\"), never the\n // description. An empty/missing name is itself a finding — fall back to\n // the hostname rather than printing nothing, or worse, printing \"\" as if\n // it were a verified fact about the business.\n const name = result.businessName && result.businessName.trim() ? result.businessName : host;\n const date = formatIsoDate(result.generatedAt);\n\n const findabilitySection = stageBody(result.checks, buildFindabilitySection);\n const lighthouseBlock = buildLighthouseBlock(result.lighthouse);\n const readabilitySection = stageBody(result.checks, buildReadabilitySection);\n\n // A real business name was resolved and handed to the engines only when\n // `result.businessName` is non-empty — the same value the pipeline\n // actually queried with (see pipeline.ts). Used to gate the probes\n // section's \"even with the name handed to them\" phrasing (Fix 6).\n // Whether the engines were actually queried with the NAME, which is not the\n // same as whether a name existed. resolveBusinessName falls back to the bare\n // domain for anything it cannot use, and this line used to read the\n // un-resolved value — so a report could claim the engines were given the name\n // \"even with the name handed to them\" while the probes had in fact searched\n // for \"stlouisroofing.com\". Resolve it the same way probes.ts does.\n const businessNameUsed = Boolean(\n result.businessName &&\n result.businessName.trim() &&\n resolveBusinessName(result.businessName, result.url) === result.businessName.trim(),\n );\n\n const fixes = result.analyze.ok\n ? (() => {\n const sorted = [...result.analyze.data.fixes].sort(\n (x, y) => IMPACT_ORDER[x.impact] - IMPACT_ORDER[y.impact],\n );\n if (sorted.length === 0) {\n // A genuinely well-optimized site: a bare empty list reads as a\n // rendering bug, not a compliment. Say so directly, and keep this\n // section as a pitch — it's the one the salesperson is sending\n // the report to make.\n return `<p>Nothing here reads as urgent — the essentials are already in place.\n Reddoor can still help push this further: tightening structured data, monitoring\n how the answer engines describe you, and keeping pace as they change.</p>`;\n }\n return `<ol>${sorted\n .map((f) => {\n const impact = escapeHtml(f.impact);\n const effort = escapeHtml(f.effort);\n return `<li><strong>${escapeHtml(f.title)}</strong> — ${escapeHtml(f.why)}\n <span class=\"muted\">(${impact} impact, ${effort} effort)</span></li>`;\n })\n .join(\"\")}</ol>`;\n })()\n : skippableStageNote(\n result.analyze.error,\n ANALYZE_SKIPPED,\n \"Skipped — the checks stage failed, so there was nothing to build fixes from.\",\n );\n\n const probesSectionFinal = result.probes.ok\n ? buildProbesSection(result.probes.data, businessNameUsed)\n : skippableStageNote(\n result.probes.error,\n PROBES_SKIPPED,\n \"This audit did not run the AI-visibility probes.\",\n );\n\n const answersSectionFinal = result.analyze.ok\n ? buildAnswersSection(result.analyze.data)\n : skippableStageNote(\n result.analyze.error,\n ANALYZE_SKIPPED,\n \"Skipped — the checks stage failed, so there were no buyer questions to check.\",\n );\n\n const description = result.analyze.ok ? result.analyze.data.business : null;\n const narrative = result.analyze.ok\n ? `<p class=\"lede\">${escapeHtml(description ?? \"\")}</p>\n <p class=\"lede\">${escapeHtml(result.analyze.data.narrative.findability)}\n ${escapeHtml(result.analyze.data.narrative.readability)}</p>`\n : \"\";\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<meta name=\"robots\" content=\"noindex\" />\n<title>Can AI find ${escapeHtml(name)}? — Reddoor audit</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n<link href=\"https://fonts.googleapis.com/css2?family=Besley:wght@400;600&display=swap\" rel=\"stylesheet\" />\n<style>${STYLES}</style>\n</head>\n<body>\n<div class=\"wrap\">\n <h1>Can AI and Google actually find ${escapeHtml(name)}?</h1>\n <p class=\"lede\"><a href=\"${escapeHtml(safeUrl(result.url))}\" target=\"_blank\" rel=\"noopener noreferrer\">${escapeHtml(result.url)}</a> · audited ${escapeHtml(date)} by Reddoor Creative</p>\n ${narrative}\n\n <p class=\"muted\">Each score below runs 0–100 — read it as a score, not a percentage.</p>\n <div class=\"scores\">\n ${scoreCard(\"Findability\", result.scores.findability, \"How easily AI and search crawlers can find and reach your site — crawl rules, sitemap, key metadata\")}\n ${scoreCard(\"Readability\", result.scores.readability, \"How much of your site's content those crawlers can actually read once they're in — most don't run JavaScript\")}\n ${scoreCard(\"Answers\", result.scores.answers, \"How many buyer questions your site's own content answers — separate from what the AI engines say back\")}\n ${scoreCard(\"AI Visibility\", result.scores.aiVisibility, \"Based on buyer questions — not questions that name the business directly\")}\n </div>\n\n <h2>What the AI engines said about you</h2>\n ${probesSectionFinal}\n\n <h2>What the crawlers can reach</h2>\n ${findabilitySection}\n ${lighthouseBlock}\n\n <h2>What the crawlers can read</h2>\n ${readabilitySection}\n\n <h2>The questions your buyers ask</h2>\n ${answersSectionFinal}\n\n <h2>What to fix first</h2>\n ${fixes}\n\n <div class=\"cta\">\n <h2>Want this fixed?</h2>\n <p>Reddoor Creative rebuilds sites so answer engines can read, quote and recommend them.\n Reply to the email this link came from, or start at\n <a href=\"https://reddoorla.com/\">reddoorla.com</a>.</p>\n </div>\n</div>\n</body>\n</html>`;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/reports/digest.ts"],"sourcesContent":["// src/reports/digest.ts\nimport { openBase, readAirtableConfig, type AirtableBase } from \"./airtable/client.js\";\nimport { listAllReports, isPendingApproval } from \"./airtable/reports.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport { listWebsites, siteSlug, type WebsiteRow } from \"./airtable/websites.js\";\nimport { defaultResendClient, type ResendClient } from \"./send/resend.js\";\nimport { isIdempotencyConflict } from \"./send/idempotency.js\";\nimport {\n collectVulnAlerts,\n collectDeliveryFailures,\n collectPreflightBlocked,\n collectLighthouseAlerts,\n collectRenovateAlerts,\n collectCiAlerts,\n collectAnalyticsFailures,\n collectTurnstileGuardrailAlerts,\n collectNotifyBounceAlerts,\n collectPrismicDriftAlerts,\n NOTIFY_BOUNCE_WINDOW_DAYS,\n} from \"../alerts/digest-collectors.js\";\nimport { diffAttention } from \"../alerts/digest-state.js\";\nimport {\n writeDigestState as writeAirtableDigestState,\n type DigestSnapshot,\n} from \"../alerts/digest-state.js\";\nimport { escapeHtml as esc } from \"../util/html.js\";\nimport { operatorEmail } from \"../util/operator.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\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 prior snapshot, from Turso (#609). Dynamically imported for the same\n * reason as fetchNotifyBounceCounts: kysely/libsql are devDependencies that\n * consuming fleet sites do not install, so a static import would crash their\n * CLI at require time.\n *\n * NOT defensive, deliberately — the Airtable read it replaces was not either.\n * Swallowing a failure here would silently badge every item NEW, which reads\n * as \"the whole fleet degraded overnight\" in the operator's inbox. A thrown\n * error is the honest outcome. */\nasync function readDigestStateFromDb(): Promise<DigestSnapshot> {\n const [{ openDb, readDbConfig }, { readDigestState }] = await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/digest-state.js\"),\n ]);\n return readDigestState(await openDb(readDbConfig()));\n}\n\n/** The write half of {@link readDigestStateFromDb}, same lazy-import rule. */\nasync function writeDigestStateToDb(next: DigestSnapshot): Promise<void> {\n const [{ openDb, readDbConfig }, { writeDigestState }] = await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/digest-state.js\"),\n ]);\n await writeDigestState(await openDb(readDbConfig()), next);\n}\n\n/** Persist the next snapshot to BOTH stores (#609 dual-write).\n *\n * Turso is the read side, so a failure there is the one that actually costs\n * correct NEW badges tomorrow — but Airtable keeps being written so the move\n * stays reversible while Phase 5 is in flight. Both are best-effort: the\n * digest has already been sent (or deliberately skipped) by the time this\n * runs, and a snapshot write failure re-news at worst.\n *\n * The DIGEST_STATE_WRITE line exists because of #585: a dual-write that\n * silently stopped running looked identical to a healthy one for weeks. One\n * line, always emitted, naming both halves. */\nasync function persistDigestState(\n base: AirtableBase,\n next: DigestSnapshot,\n writeState?: (snap: DigestSnapshot) => Promise<void>,\n): Promise<void> {\n let turso = 0;\n let airtable = 0;\n try {\n await (writeState ?? writeDigestStateToDb)(next);\n turso = 1;\n } catch (e) {\n console.warn(`⚠ digest state write failed (turso): ${(e as Error).message}`);\n }\n try {\n await writeAirtableDigestState(base, next);\n airtable = 1;\n } catch (e) {\n console.warn(`⚠ digest state write failed (airtable): ${(e as Error).message}`);\n }\n console.log(`DIGEST_STATE_WRITE turso=${turso} airtable=${airtable}`);\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 // Same collector the cockpit runs, over the same persisted `Prismic Models`\n // columns and the same keys — so the snapshot this digest writes is the one\n // the cockpit diffs NEW/WORSE against, and the two surfaces agree.\n ...runCollector(\"prismic-drift\", () => collectPrismicDriftAlerts(websites, deps.baseUrl, now)),\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 * The prior-run snapshot store (#609). Production omits it and gets Turso.\n *\n * Injectable because the read is deliberately NOT defensive: swallowing a\n * failure would badge every item NEW, which lands in the operator's inbox\n * reading as \"the whole fleet degraded overnight\". That makes Turso a hard\n * requirement of a real run — so tests, which have no libSQL, need a seam\n * rather than an env var.\n */\n digestState?: {\n read: () => Promise<DigestSnapshot>;\n write: (snap: DigestSnapshot) => Promise<void>;\n };\n /**\n * The run clock. Production omits it and gets `new Date()`.\n *\n * Injectable because several collectors this run feeds are AGE-SENSITIVE —\n * `collectPrismicDriftAlerts` alone changes an item's key and its wording once\n * a verdict crosses `PRISMIC_DRIFT_STALE_DAYS` — so a digest test that cannot\n * fix the clock is asserting on the day it happens to run. Two did, and both\n * went red four days after they were written, reporting the staleness wording\n * as if the drift wiring had broken. `collectAttention` already takes a `now`\n * for exactly this reason; this threads the same lever one layer out.\n */\n now?: Date;\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 = options.now ?? 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 // #609: Turso is the read side now. Airtable keeps being WRITTEN below\n // (dual-write, Phase 5's pattern everywhere else) so the move stays\n // reversible, but nothing reads it any more.\n const prior = await (options.digestState?.read ?? readDigestStateFromDb)();\n const { tagged, next } = diffAttention(collected, prior, digestDateKey(today));\n // The operator only HEARS about a vuln once Renovate's auto-fix is exhausted\n // (tried and failed a couple of nightly cycles) — before that the fleet is\n // still self-patching and the cockpit's amber Watch band is the only surface.\n // Filter the EMAIL list only: `next` (written below) must keep every vuln key\n // so the cockpit's diff against this same snapshot stays consistent, and so\n // the exhausted-flip diffs as WORSE instead of arriving pre-badged-away.\n const needsAttention = tagged.filter(\n (it) => it.kind !== \"vuln\" || it.autoFixExhausted === true,\n );\n\n // No-noise default: skip entirely when there's nothing to report.\n if (readyForYourYes.length === 0 && needsAttention.length === 0) {\n // Persist `next` even on a skip: a key that resolved on a quiet day clears so a\n // later recurrence diffs as NEW (spec §10), and — since the skip can now fire\n // with muted pre-exhaustion vulns collected — their keys keep snapshotting so\n // the eventual exhausted-flip diffs as WORSE. Wrapped: a write failure can't\n // fail the skip.\n await persistDigestState(base, next, options.digestState?.write);\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 = [operatorEmail()];\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 await persistDigestState(base, next, options.digestState?.write);\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqDA,IAAM,OAAO;AACb,IAAM,MAAM;AAGZ,IAAM,eAAe,SAAS,GAAG;AAEjC,SAAS,aAAa,OAA4B;AAChD,QAAM,UAAU,oBAAoB,GAAG;AACvC,MAAI,MAAM,WAAW,GAAG;AACtB,WAAO,GAAG,OAAO,mBAAmB,IAAI;AAAA,EAC1C;AACA,QAAM,OAAO,MACV,IAAI,CAAC,OAAO;AACX,UAAM,UAAU,GAAG,aAAa,WAAW,UAAU,IAAI,GAAG,eAAe;AAC3E,UAAM,OAAO,UACT,YAAY,WAAI,OAAO,CAAC,YAAY,YAAY,+BAChD;AACJ,WAAO;AAAA;AAAA,2BAEc,IAAI;AAAA,uCACQ,WAAI,GAAG,QAAQ,CAAC,oBAAe,WAAI,GAAG,UAAU,CAAC,KAAK,WAAI,GAAG,MAAM,CAAC;AAAA,mBAC7F,IAAI;AAAA;AAAA;AAAA,EAGd,CAAC,EACA,KAAK,EAAE;AACV,SAAO,GAAG,OAAO,wEAAwE,IAAI;AAC/F;AAEA,IAAM,iBAAoD,EAAE,UAAU,GAAG,SAAS,EAAE;AAGpF,SAAS,eAAe,QAAkC;AACxD,MAAI,WAAW;AACb,WAAO,wBAAwB,GAAG;AACpC,MAAI,WAAW;AACb,WAAO,wBAAwB,GAAG;AACpC,SAAO;AACT;AAEA,SAAS,iBAAiB,OAAgC;AACxD,QAAM,UAAU,oBAAoB,GAAG;AACvC,MAAI,MAAM,WAAW,GAAG;AACtB,WAAO,GAAG,OAAO,mBAAmB,IAAI;AAAA,EAC1C;AAIA,QAAM,SAAS,oBAAI,IAA6B;AAChD,aAAW,MAAM,OAAO;AACtB,UAAM,SAAS,OAAO,IAAI,GAAG,QAAQ;AACrC,QAAI,OAAQ,QAAO,KAAK,EAAE;AAAA,QACrB,QAAO,IAAI,GAAG,UAAU,CAAC,EAAE,CAAC;AAAA,EACnC;AAEA,QAAM,SAAS,CAAC,GAAG,OAAO,QAAQ,CAAC,EAChC,IAAI,CAAC,CAAC,UAAU,SAAS,MAAM;AAC9B,UAAM,SAAS,CAAC,GAAG,SAAS,EAAE;AAAA,MAC5B,CAAC,GAAG,MAAM,eAAe,EAAE,QAAQ,IAAI,eAAe,EAAE,QAAQ;AAAA,IAClE;AACA,UAAM,OAAO,OACV,IAAI,CAAC,OAAO;AACX,YAAM,UAAU,GAAG,KAAK,WAAW,UAAU,IAAI,GAAG,MAAM;AAC1D,YAAM,YAAY,UACd,YAAY,WAAI,OAAO,CAAC,YAAY,YAAY,KAAK,WAAI,GAAG,KAAK,CAAC,SAClE,WAAI,GAAG,KAAK;AAChB,aAAO;AAAA;AAAA,+BAEc,IAAI,yFAAyF,eAAe,GAAG,MAAM,CAAC,GAAG,SAAS;AAAA;AAAA,IAEzJ,CAAC,EACA,KAAK,EAAE;AACV,WAAO;AAAA;AAAA,mHAEsG,WAAI,QAAQ,CAAC;AAAA;AAAA,QAExH,IAAI;AAAA,EACR,CAAC,EACA,KAAK,EAAE;AAEV,SAAO,GAAG,OAAO,wEAAwE,MAAM;AACjG;AAKA,SAAS,mBAAmB,GAAwD;AAClF,MAAI,CAAC,EAAG,QAAO;AACf,MAAI,EAAE,UAAU,KAAK,EAAE,YAAY,KAAK,EAAE,aAAa,EAAG,QAAO;AACjE,QAAM,UAAU,oBAAoB,GAAG;AAGvC,QAAM,SAAS;AAAA,IACb,GAAG,EAAE,KAAK,YAAY,EAAE,UAAU,IAAI,KAAK,GAAG;AAAA,IAC9C,GAAG,EAAE,OAAO,0BAA0B,EAAE,YAAY,IAAI,KAAK,GAAG;AAAA,IAChE,GAAG,EAAE,QAAQ;AAAA,EACf,EAAE,KAAK,QAAK;AACZ,QAAM,YAAY,mBAAmB,IAAI,8DAA8D,MAAM;AAC7G,MAAI,EAAE,OAAO,WAAW,EAAG,QAAO,GAAG,OAAO,GAAG,SAAS;AACxD,QAAM,OAAO,EAAE,OACZ,IAAI,CAAC,SAAS;AACb,UAAM,QAAkB,CAAC;AACzB,QAAI,KAAK,QAAQ,EAAG,OAAM,KAAK,GAAG,KAAK,KAAK,QAAQ,KAAK,UAAU,IAAI,KAAK,GAAG,EAAE;AACjF,QAAI,KAAK,UAAU,EAAG,OAAM,KAAK,GAAG,KAAK,OAAO,UAAU,KAAK,YAAY,IAAI,KAAK,GAAG,EAAE;AACzF,QAAI,KAAK,WAAW,EAAG,OAAM,KAAK,GAAG,KAAK,QAAQ,gBAAgB;AAClE,WAAO;AAAA;AAAA,2BAEc,IAAI;AAAA,uCACQ,WAAI,KAAK,QAAQ,CAAC,oBAAe,MAAM,KAAK,QAAK,CAAC;AAAA;AAAA;AAAA,EAGrF,CAAC,EACA,KAAK,EAAE;AACV,SAAO,GAAG,OAAO,GAAG,SAAS,8EAA8E,IAAI;AACjH;AAEA,IAAM,eAAe;AAGrB,SAAS,cAAc,GAAiB;AACtC,SAAO,EAAE,YAAY,EAAE,MAAM,GAAG,EAAE;AACpC;AAaA,eAAsB,oBAAoB,MAA0C;AAClF,UAAQ,MAAM,eAAe,IAAI,GAAG,OAAO,iBAAiB;AAC9D;AAgCA,eAAe,wBAAwB,KAAiD;AACtF,MAAI;AACF,UAAM,CAAC,EAAE,QAAQ,aAAa,GAAG,EAAE,yBAAyB,GAAG,EAAE,gBAAgB,CAAC,IAChF,MAAM,QAAQ,IAAI;AAAA,MAChB,OAAO,sBAAiB;AAAA,MACxB,OAAO,2BAAsB;AAAA,MAC7B,OAAO,0BAAqB;AAAA,IAC9B,CAAC;AACH,UAAM,KAAK,MAAM,OAAO,aAAa,CAAC;AACtC,WAAO,MAAM,yBAAyB,IAAI,gBAAgB,KAAK,yBAAyB,CAAC;AAAA,EAC3F,SAAS,GAAG;AACV,YAAQ,KAAK,qDAAiD,EAAY,OAAO,EAAE;AACnF,WAAO,oBAAI,IAAI;AAAA,EACjB;AACF;AAWA,eAAe,wBAAiD;AAC9D,QAAM,CAAC,EAAE,QAAQ,aAAa,GAAG,EAAE,gBAAgB,CAAC,IAAI,MAAM,QAAQ,IAAI;AAAA,IACxE,OAAO,sBAAiB;AAAA,IACxB,OAAO,4BAAuB;AAAA,EAChC,CAAC;AACD,SAAO,gBAAgB,MAAM,OAAO,aAAa,CAAC,CAAC;AACrD;AAGA,eAAe,qBAAqB,MAAqC;AACvE,QAAM,CAAC,EAAE,QAAQ,aAAa,GAAG,EAAE,kBAAAA,kBAAiB,CAAC,IAAI,MAAM,QAAQ,IAAI;AAAA,IACzE,OAAO,sBAAiB;AAAA,IACxB,OAAO,4BAAuB;AAAA,EAChC,CAAC;AACD,QAAMA,kBAAiB,MAAM,OAAO,aAAa,CAAC,GAAG,IAAI;AAC3D;AAaA,eAAe,mBACb,MACA,MACA,YACe;AACf,MAAI,QAAQ;AACZ,MAAI,WAAW;AACf,MAAI;AACF,WAAO,cAAc,sBAAsB,IAAI;AAC/C,YAAQ;AAAA,EACV,SAAS,GAAG;AACV,YAAQ,KAAK,6CAAyC,EAAY,OAAO,EAAE;AAAA,EAC7E;AACA,MAAI;AACF,UAAM,iBAAyB,MAAM,IAAI;AACzC,eAAW;AAAA,EACb,SAAS,GAAG;AACV,YAAQ,KAAK,gDAA4C,EAAY,OAAO,EAAE;AAAA,EAChF;AACA,UAAQ,IAAI,4BAA4B,KAAK,aAAa,QAAQ,EAAE;AACtE;AAIA,IAAM,+BAA+B,KAAK,KAAK,KAAK;AAQpD,eAAe,6BACb,KAC2D;AAC3D,MAAI;AACF,UAAM,CAAC,EAAE,QAAQ,aAAa,GAAG,EAAE,4BAA4B,CAAC,IAAI,MAAM,QAAQ,IAAI;AAAA,MACpF,OAAO,sBAAiB;AAAA,MACxB,OAAO,2BAAsB;AAAA,IAC/B,CAAC;AACD,UAAM,KAAK,MAAM,OAAO,aAAa,CAAC;AACtC,WAAO,MAAM;AAAA,MACX;AAAA,MACA,IAAI,KAAK,IAAI,QAAQ,IAAI,4BAA4B,EAAE,YAAY;AAAA,IACrE;AAAA,EACF,SAAS,GAAG;AACV,YAAQ,KAAK,sDAAkD,EAAY,OAAO,EAAE;AACpF,WAAO;AAAA,EACT;AACF;AAOO,SAAS,8BACd,QACA,WACiC;AACjC,MAAI,WAAW,KAAM,QAAO;AAC5B,MAAI,QAAQ;AACZ,MAAI,UAAU;AACd,MAAI,WAAW;AACf,QAAM,SAA6C,CAAC;AACpD,aAAW,CAAC,QAAQ,CAAC,KAAK,QAAQ;AAChC,aAAS,EAAE;AACX,eAAW,EAAE;AACb,gBAAY,EAAE;AACd,UAAM,OAAO,UAAU,IAAI,MAAM;AACjC,QAAI,QAAQ,EAAE,QAAQ,EAAE,UAAU,EAAE,WAAW,GAAG;AAChD,aAAO,KAAK,EAAE,UAAU,KAAK,MAAM,GAAG,EAAE,CAAC;AAAA,IAC3C;AAAA,EACF;AACA,SAAO;AAAA,IACL,CAAC,GAAG,MACF,EAAE,QAAQ,EAAE,UAAU,EAAE,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,aAC5D,EAAE,SAAS,cAAc,EAAE,QAAQ;AAAA,EACvC;AACA,SAAO,EAAE,OAAO,SAAS,UAAU,OAAO;AAC5C;AAIA,SAAS,aAAa,OAAe,IAA4C;AAC/E,MAAI;AACF,WAAO,GAAG;AAAA,EACZ,SAAS,GAAG;AACV,YAAQ,KAAK,+BAA0B,KAAK,aAAc,EAAY,OAAO,EAAE;AAC/E,WAAO,CAAC;AAAA,EACV;AACF;AAkBA,eAAsB,iBAAiB,MAAsD;AAC3F,QAAM,UAAU,KAAK,WAAY,MAAM,eAAe,KAAK,IAAI;AAC/D,QAAM,WAAW,KAAK,YAAa,MAAM,aAAa,KAAK,IAAI;AAC/D,QAAM,MAAM,KAAK,OAAO,oBAAI,KAAK;AACjC,QAAM,gBAAgB,KAAK,iBAAkB,MAAM,wBAAwB,GAAG;AAC9E,QAAM,YAAY,IAAI,IAAwB,SAAS,IAAI,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;AAC5E,SAAO;AAAA,IACL,GAAG,aAAa,QAAQ,MAAM,kBAAkB,UAAU,KAAK,OAAO,CAAC;AAAA,IACvE,GAAG,aAAa,YAAY,MAAM,wBAAwB,SAAS,WAAW,KAAK,OAAO,CAAC;AAAA,IAC3F,GAAG,aAAa,aAAa,MAAM,wBAAwB,SAAS,WAAW,KAAK,OAAO,CAAC;AAAA,IAC5F,GAAG,aAAa,cAAc,MAAM,wBAAwB,UAAU,KAAK,OAAO,CAAC;AAAA,IACnF,GAAG,aAAa,YAAY,MAAM,sBAAsB,UAAU,KAAK,SAAS,GAAG,CAAC;AAAA,IACpF,GAAG,aAAa,MAAM,MAAM,gBAAgB,UAAU,KAAK,SAAS,GAAG,CAAC;AAAA,IACxE,GAAG,aAAa,aAAa,MAAM,yBAAyB,UAAU,KAAK,SAAS,GAAG,CAAC;AAAA,IACxF,GAAG;AAAA,MAAa;AAAA,MAAa,MAC3B,gCAAgC,UAAU,KAAK,SAAS,GAAG;AAAA,IAC7D;AAAA,IACA,GAAG;AAAA,MAAa;AAAA,MAAiB,MAC/B,0BAA0B,UAAU,eAAe,KAAK,OAAO;AAAA,IACjE;AAAA;AAAA;AAAA;AAAA,IAIA,GAAG,aAAa,iBAAiB,MAAM,0BAA0B,UAAU,KAAK,SAAS,GAAG,CAAC;AAAA,EAC/F;AACF;AA0CA,eAAsB,UACpB,SAC2C;AAE3C,QAAM,QAAQ,QAAQ,OAAO,oBAAI,KAAK;AACtC,MAAI;AACF,UAAM,OAAO,QAAQ,QAAQ,SAAS,mBAAmB,CAAC;AAI1D,UAAM,UAAU,MAAM,eAAe,IAAI;AACzC,UAAM,WAAW,MAAM,aAAa,IAAI;AACxC,UAAM,QAAQ,IAAI,IAAI,SAAS,IAAI,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;AAEpD,UAAM,UAAU,QAAQ,OAAO,iBAAiB;AAEhD,UAAM,kBAA+B,CAAC;AACtC,UAAM,UAAU,QAAQ,QAAQ,QAAQ,OAAO,EAAE;AACjD,eAAW,KAAK,SAAS;AACvB,YAAM,OAAO,MAAM,IAAI,EAAE,MAAM;AAC/B,UAAI,CAAC,KAAM;AAIX,YAAM,OAAO,SAAS,KAAK,IAAI;AAC/B,sBAAgB,KAAK;AAAA,QACnB,UAAU,KAAK;AAAA,QACf,YAAY,EAAE;AAAA,QACd,QAAQ,EAAE,UAAU;AAAA,QACpB,cAAc,OAAO,GAAG,OAAO,MAAM,IAAI,KAAK;AAAA,MAChD,CAAC;AAAA,IACH;AAMA,UAAM,YAAY,MAAM,iBAAiB;AAAA,MACvC;AAAA,MACA,SAAS,QAAQ;AAAA,MACjB;AAAA,MACA;AAAA,MACA,KAAK;AAAA,IACP,CAAC;AAID,UAAM,QAAQ,OAAO,QAAQ,aAAa,QAAQ,uBAAuB;AACzE,UAAM,EAAE,QAAQ,KAAK,IAAI,cAAc,WAAW,OAAO,cAAc,KAAK,CAAC;AAO7E,UAAM,iBAAiB,OAAO;AAAA,MAC5B,CAAC,OAAO,GAAG,SAAS,UAAU,GAAG,qBAAqB;AAAA,IACxD;AAGA,QAAI,gBAAgB,WAAW,KAAK,eAAe,WAAW,GAAG;AAM/D,YAAM,mBAAmB,MAAM,MAAM,QAAQ,aAAa,KAAK;AAC/D,aAAO,EAAE,QAAQ,4DAA4D,MAAM,EAAE;AAAA,IACvF;AAMA,UAAM,SACJ,QAAQ,qBAAqB,SACzB,QAAQ,mBACR,MAAM,6BAA6B,KAAK;AAC9C,UAAM,cAAc,8BAA8B,QAAQ,KAAK;AAE/D,UAAM,OAAO,iBAAiB,EAAE,iBAAiB,gBAAgB,YAAY,CAAC;AAC9E,UAAM,SAAS,QAAQ,UAAU,oBAAoB;AACrD,UAAM,KAAK,CAAC,cAAc,CAAC;AAC3B,UAAM,IAAI,gBAAgB;AAC1B,UAAM,aAAa,MAAM,IAAI,WAAW;AACxC,QAAI;AACJ,QAAI;AACF,eAAS,MAAM,OAAO,KAAK;AAAA,QACzB,MAAM;AAAA,QACN;AAAA,QACA,SAAS,qBAAgB,cAAc,KAAK,CAAC,KAAK,CAAC,IAAI,UAAU;AAAA,QACjE;AAAA,QACA,gBAAgB,UAAU,cAAc,KAAK,CAAC;AAAA,MAChD,CAAC;AAAA,IACH,SAAS,KAAK;AAaZ,UAAI,sBAAsB,GAAG,GAAG;AAG9B,eAAO;AAAA,UACL,QACE;AAAA,UACF,MAAM;AAAA,QACR;AAAA,MACF;AACA,YAAM;AAAA,IACR;AAKA,UAAM,mBAAmB,MAAM,MAAM,QAAQ,aAAa,KAAK;AAC/D,WAAO,EAAE,QAAQ,kBAAkB,GAAG,KAAK,IAAI,CAAC,KAAK,OAAO,SAAS,KAAK,MAAM,EAAE;AAAA,EACpF,SAAS,KAAK;AAGZ,QAAI,OAAQ,IAA+B,aAAa,UAAU;AAChE,YAAM;AAAA,IACR;AACA,UAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC/D,WAAO,EAAE,QAAQ,kBAAkB,OAAO,IAAI,MAAM,EAAE;AAAA,EACxD;AACF;AAIO,SAAS,iBAAiB,UAAkC;AACjE,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mCAU0B,GAAG;AAAA,kBACpB,aAAa,SAAS,eAAe,CAAC;AAAA,kBACtC,iBAAiB,SAAS,cAAc,CAAC;AAAA,kBACzC,mBAAmB,SAAS,WAAW,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAS1D;","names":["writeDigestState"]}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
// src/db/digest-state.ts
|
|
2
|
-
var DIGEST_STATE_ID = "default";
|
|
3
|
-
async function readDigestState(db) {
|
|
4
|
-
const row = await db.selectFrom("digest_state").select("snapshot").where("id", "=", DIGEST_STATE_ID).executeTakeFirst();
|
|
5
|
-
if (!row) return {};
|
|
6
|
-
try {
|
|
7
|
-
return JSON.parse(row.snapshot);
|
|
8
|
-
} catch {
|
|
9
|
-
return {};
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
async function writeDigestState(db, snap, updatedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
13
|
-
const row = {
|
|
14
|
-
id: DIGEST_STATE_ID,
|
|
15
|
-
snapshot: JSON.stringify(snap),
|
|
16
|
-
updated_at: updatedAt
|
|
17
|
-
};
|
|
18
|
-
await db.insertInto("digest_state").values(row).onConflict((oc) => oc.column("id").doUpdateSet(row)).execute();
|
|
19
|
-
}
|
|
20
|
-
export {
|
|
21
|
-
DIGEST_STATE_ID,
|
|
22
|
-
readDigestState,
|
|
23
|
-
writeDigestState
|
|
24
|
-
};
|
|
25
|
-
//# sourceMappingURL=digest-state-CRZRNGDI.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/db/digest-state.ts"],"sourcesContent":["/** #609 (#539 Phase 5): the digest's prior-run snapshot, on Turso.\n *\n * Unlike every other Phase 5 slice this is a MIGRATION, not a mirror — there\n * was no Turso table to dual-write into, so these REPLACE the Airtable pair in\n * `src/alerts/digest-state.ts` rather than shadowing them. Nothing writes the\n * Airtable \"Digest State\" table after this, so parity does not cover the new\n * one: it has no counterpart to compare against.\n *\n * The pure half — `diffAttention`, and the `DigestSnapshot` type it is written\n * against — stays exactly where it is. Only the IO moved.\n *\n * Stored as ONE row holding the whole snapshot as JSON. Both readers\n * (`runDigest`'s diff and the fleet homepage's NEW badges) need the entire map,\n * so a keyed table would buy nothing on reads and its \"give me everything\"\n * query would be a raw scan needing a justified entry in the EXPLAIN gate's\n * allowlist. One row by primary key needs neither.\n *\n * Worth stating plainly: the homepage's read of this used to be an AIRTABLE\n * call on a request path — a Phase 2 leftover, since digest state was never in\n * that phase's scope. Moving it removes a live dependency, which is the same\n * failure class as the 2026-08-17 quota outage.\n */\nimport type { Db } from \"./client.js\";\nimport type { DigestSnapshot } from \"../alerts/digest-state.js\";\n\n/** The singleton row's key. A constant rather than a magic string at each call\n * site, so the \"there is exactly one row\" invariant is visible in one place. */\nexport const DIGEST_STATE_ID = \"default\";\n\n/**\n * The prior snapshot, or `{}` when there is none.\n *\n * A missing row and a malformed blob BOTH read as `{}` — the Airtable reader's\n * contract, kept verbatim. The digest runs unattended on a cron, so a bad blob\n * must cost accurate NEW badges for one run, never the whole send.\n */\nexport async function readDigestState(db: Db): Promise<DigestSnapshot> {\n const row = await db\n .selectFrom(\"digest_state\")\n .select(\"snapshot\")\n .where(\"id\", \"=\", DIGEST_STATE_ID)\n .executeTakeFirst();\n if (!row) return {};\n try {\n return JSON.parse(row.snapshot) as DigestSnapshot;\n } catch {\n return {};\n }\n}\n\n/**\n * Persist the next snapshot, replacing whatever was there.\n *\n * An upsert on the constant id rather than a get-then-create: the Airtable\n * version needed two round-trips to decide, and here the singleton invariant is\n * enforced by the primary key instead of by the code that happens to run.\n */\nexport async function writeDigestState(\n db: Db,\n snap: DigestSnapshot,\n updatedAt: string = new Date().toISOString(),\n): Promise<void> {\n const row = {\n id: DIGEST_STATE_ID,\n snapshot: JSON.stringify(snap),\n updated_at: updatedAt,\n };\n await db\n .insertInto(\"digest_state\")\n .values(row)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(row))\n .execute();\n}\n"],"mappings":";AA2BO,IAAM,kBAAkB;AAS/B,eAAsB,gBAAgB,IAAiC;AACrE,QAAM,MAAM,MAAM,GACf,WAAW,cAAc,EACzB,OAAO,UAAU,EACjB,MAAM,MAAM,KAAK,eAAe,EAChC,iBAAiB;AACpB,MAAI,CAAC,IAAK,QAAO,CAAC;AAClB,MAAI;AACF,WAAO,KAAK,MAAM,IAAI,QAAQ;AAAA,EAChC,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AASA,eAAsB,iBACpB,IACA,MACA,aAAoB,oBAAI,KAAK,GAAE,YAAY,GAC5B;AACf,QAAM,MAAM;AAAA,IACV,IAAI;AAAA,IACJ,UAAU,KAAK,UAAU,IAAI;AAAA,IAC7B,YAAY;AAAA,EACd;AACA,QAAM,GACH,WAAW,cAAc,EACzB,OAAO,GAAG,EACV,WAAW,CAAC,OAAO,GAAG,OAAO,IAAI,EAAE,YAAY,GAAG,CAAC,EACnD,QAAQ;AACb;","names":[]}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|