From 4df320c5be7082feb32a7a365529358efe0ef8d9 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 25 Jul 2026 23:22:16 +0000 Subject: [PATCH] feat(pdf): quote remediation on the report cover, priced from its findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Puts a "want us to fix this for you?" offer at the top of every audit PDF, with the price estimated from that report's own findings rather than a flat figure — billed at $100/hour and split into the two kinds of work: AI-assisted metadata, schema, alt text, template variables, placeholder copy, dead links — the same class of change the GitHub auto-fix agent already opens PRs for Manual DNS/registrar records, infra and performance work, and anything needing facts only the client has: real metrics, customer quotes, product screenshots, positioning calls lib/audit/quote.ts maps each check_key to an hours-per-occurrence pair via ordered rules, so a content-heavy report prices differently from a markup-heavy one of the same size. A warn costs 0.6 of a fail. Fixed baseline overhead (intake, plan, verification re-scan, handoff) means a nearly-clean site still quotes something that covers confirming it is clean, and a 120-hour ceiling flags catastrophic reports for a scoping call instead of printing an absurd number. Two things that would otherwise overbill: - Findings are deduplicated by check_key, keeping the worst status. A consolidated scan-run PDF carries one findings set per engine, and several engines flag the same defect — a missing meta description is one fix whether one engine noticed or five did. - rec.*/todo.*/slop.score/slop.coverage/crawl.* are non-billable; they restate or summarise defects counted elsewhere. Per-page slop findings are priced cheaply and shared-template fixes (slop.systemic.*) more richly, so the quote reflects "fix the template once" rather than "fix thirty pages". Rendered on the cover by htmlDocument, so it reaches all three PDF paths: the per-engine email, the consolidated scan-run email, and the on-demand /pdf endpoint (which now accepts a quote in its payload; the app route computes it from audit_findings). The lookup is best-effort — if it fails the PDF still renders, just without the offer. Calibration against live reports: crawlproof.com (score 58) $2,500 / 24.5h; news.ycombinator.com (38) $4,100 / 41h; example.com (37) $4,400 / 44h; a Slop-Score-only report $1,800 / 18h. 21 tests cover the classification, dedupe, guardrails and rounding. Verified by rendering a real PDF and inspecting the cover. Co-Authored-By: Claude Opus 5 (1M context) --- app/api/audits/[id]/pdf/route.ts | 18 +++ lib/audit/quote.ts | 239 +++++++++++++++++++++++++++++++ lib/markdown.ts | 91 ++++++++++++ tests/quote.test.ts | 195 +++++++++++++++++++++++++ worker/index.ts | 24 ++++ 5 files changed, 567 insertions(+) create mode 100644 lib/audit/quote.ts create mode 100644 tests/quote.test.ts diff --git a/app/api/audits/[id]/pdf/route.ts b/app/api/audits/[id]/pdf/route.ts index 7e9ab995..561a4b27 100644 --- a/app/api/audits/[id]/pdf/route.ts +++ b/app/api/audits/[id]/pdf/route.ts @@ -5,6 +5,8 @@ import { buildScanRunMarkdown, type SummaryRow, } from "@/lib/audit/summary-markdown"; +import { quoteFromFindings } from "@/lib/audit/quote"; +import type { Finding } from "@/lib/audit/types"; export const runtime = "nodejs"; @@ -75,6 +77,9 @@ export async function GET( let markdown = audit.report_markdown; let title = `AEO Audit — ${new URL(audit.target_url).hostname}`; let scoreForPdf: number | null = audit.score; + // Audit ids the quote is priced from — every engine in the run, so a + // consolidated PDF quotes the whole job rather than one engine's slice. + let quoteAuditIds: string[] = [audit.id as string]; if (audit.scan_run_id) { const projectId = (audit as { project_id?: string | null }).project_id; @@ -103,9 +108,21 @@ export async function GET( ) : null; title = `AEO Audit — ${new URL(audit.target_url).hostname} (${rows.length} engines)`; + quoteAuditIds = rows.filter((r) => r.status === "complete").map((r) => r.id); } } + // Price the remediation offer from the report's own findings. Best-effort: + // if the lookup fails the PDF still renders, just without the offer. + const { data: findingRows } = await supabase + .from("audit_findings") + .select("section, check_key, status, title, priority") + .in("audit_id", quoteAuditIds); + const quote = + findingRows && findingRows.length > 0 + ? quoteFromFindings(findingRows as unknown as Finding[]) + : undefined; + const workerRes = await fetch(`${env.workerUrl}/pdf`, { method: "POST", headers: { "content-type": "application/json", "x-worker-secret": env.workerSecret }, @@ -114,6 +131,7 @@ export async function GET( title, target: audit.target_url, score: scoreForPdf, + quote, }), }); if (!workerRes.ok) { diff --git a/lib/audit/quote.ts b/lib/audit/quote.ts new file mode 100644 index 00000000..6de490c0 --- /dev/null +++ b/lib/audit/quote.ts @@ -0,0 +1,239 @@ +// Remediation quote — "we'll fix this for you" pricing, derived from the +// report's own findings rather than picked out of the air. +// +// Every finding is classified by how it actually gets fixed: +// +// AI-assisted work our automation does — metadata, schema, alt text, +// template variables, placeholder copy, dead links. This is +// the same class of change the GitHub auto-fix agent already +// opens PRs for, so it's cheap and fast. +// Manual work a human has to do — DNS/registrar changes, infra and +// performance work, and anything requiring facts only the +// client has: real metrics, customer quotes, product +// screenshots, positioning decisions, redirect strategy. +// +// Both are billed at the same hourly rate; the split exists so the client can +// see what they're paying for and why a content-heavy report costs more than a +// markup-heavy one. + +import type { Finding } from "./types"; + +/** Blended hourly rate, USD. */ +export const HOURLY_RATE_USD = 100; + +/** Score we commit to reaching across every engine in the report. */ +export const TARGET_SCORE = 90; + +// Sanity ceiling. Past this the engagement is a rebuild, not a remediation, +// and should be scoped by hand rather than quoted off a scan. +const MAX_BILLABLE_HOURS = 120; + +// Fixed overhead on any engagement: intake, an implementation plan, a +// verification re-scan per engine, and the handoff writeup. Without this a +// nearly-clean site would quote a couple of hundred dollars, which doesn't +// cover the work of confirming it's actually clean. +const BASELINE = { ai: 1, manual: 3 }; + +type Effort = { ai: number; manual: number }; + +// Hours per occurrence. Ordered — first matching rule wins. +const EFFORT_RULES: Array<{ test: RegExp; effort: Effort; label: string }> = [ + // Content that only the client can supply: real numbers, named customers, + // original screenshots. AI drafts it; a human still has to source the truth. + { + test: /^(content\.no_first_party_evidence|content\.thin|slop\.site\.content\.(near_duplicate|boilerplate_intro)|homepage\.word_count|positioning\.|data\.|missing\.)/, + effort: { ai: 0.5, manual: 1.5 }, + label: "Original content & positioning", + }, + // Registrar / mail-provider records. No automation can touch these without + // credentials we don't hold. + { + test: /^(dns\.|aibot\.|robots\.|sitemap\.|wellknown\.|spf|dkim|dmarc|mta|bimi)/, + effort: { ai: 0.2, manual: 0.6 }, + label: "DNS, robots & crawler access", + }, + // Infra: caching, JS payload, server rendering, headers. + { + test: /^(performance\.|homepage\.load_time|content\.text_ratio|render\.|security\.)/, + effort: { ai: 0.4, manual: 1 }, + label: "Performance & security infrastructure", + }, + // Template-level defects — fixed once, fixes every page. Higher one-time + // cost than a single page, far cheaper than the pages it resolves. + { + test: /^slop\.systemic\./, + effort: { ai: 1, manual: 0.5 }, + label: "Shared template fixes", + }, + // Per-page slop. Deliberately cheap per page: most instances are resolved by + // the template fix above, and the rest are mechanical. + { + test: /^slop\.page\./, + effort: { ai: 0.15, manual: 0.1 }, + label: "Per-page cleanup", + }, + // Structured data. + { + test: /^(schema\.|spec\.)/, + effort: { ai: 0.4, manual: 0.15 }, + label: "Schema & structured data", + }, + // Markup, metadata, links, images — the automation's home turf. + { + test: /^(homepage\.|meta\.|content\.|links\.|images\.|geo\.|slop\.site\.)/, + effort: { ai: 0.35, manual: 0.1 }, + label: "Markup, metadata & media", + }, +]; + +const DEFAULT_EFFORT: Effort = { ai: 0.3, manual: 0.2 }; +const DEFAULT_LABEL = "Other findings"; + +// Findings that are summaries or scaffolding, not work items — counting them +// would bill the client twice for the same defect. +const NON_BILLABLE = + /^(rec\.|todo\.|slop\.score$|slop\.coverage$|crawl\.|links\.crawl_coverage$)/; + +export type QuoteDriver = { + label: string; + count: number; + aiHours: number; + manualHours: number; +}; + +export type Quote = { + rateUsd: number; + targetScore: number; + aiHours: number; + manualHours: number; + totalHours: number; + amountUsd: number; + issueCount: number; + drivers: QuoteDriver[]; + /** True when the estimate hit the ceiling and needs a human to scope it. */ + cappedForScoping: boolean; +}; + +function roundHalf(n: number): number { + return Math.round(n * 2) / 2; +} + +/** + * Price a remediation engagement from a report's findings. + * + * Only `fail` and `warn` findings are billable — a `pass` is not work. The + * result is rounded to the nearest half-hour and $100 so it reads as a quote + * rather than a spreadsheet cell. + */ +export function quoteFromFindings(findings: Finding[]): Quote { + // Deduplicate by check_key, keeping the worst status. A consolidated + // scan-run report contains one findings set per engine, and several engines + // flag the same defect (a missing meta description is one fix, whether + // Claude, Gemini and the rule engine all noticed it or only one did). + // Without this the same work is billed once per engine that spotted it. + const worst = new Map(); + for (const f of findings) { + if (f.status !== "fail" && f.status !== "warn") continue; + if (NON_BILLABLE.test(f.check_key)) continue; + const seen = worst.get(f.check_key); + if (!seen || (seen.status === "warn" && f.status === "fail")) { + worst.set(f.check_key, f); + } + } + const billable = Array.from(worst.values()); + + const byLabel = new Map(); + let ai = BASELINE.ai; + let manual = BASELINE.manual; + + for (const f of billable) { + const rule = EFFORT_RULES.find((r) => r.test.test(f.check_key)); + const effort = rule?.effort ?? DEFAULT_EFFORT; + const label = rule?.label ?? DEFAULT_LABEL; + // A `warn` is a partial defect — it costs less to clear than a hard fail. + const weight = f.status === "fail" ? 1 : 0.6; + const aiHours = effort.ai * weight; + const manualHours = effort.manual * weight; + ai += aiHours; + manual += manualHours; + + const existing = byLabel.get(label); + if (existing) { + existing.count += 1; + existing.aiHours += aiHours; + existing.manualHours += manualHours; + } else { + byLabel.set(label, { label, count: 1, aiHours, manualHours }); + } + } + + let aiHours = roundHalf(ai); + let manualHours = roundHalf(manual); + let cappedForScoping = false; + if (aiHours + manualHours > MAX_BILLABLE_HOURS) { + // Scale both down proportionally so the split stays meaningful, and flag + // that the real number needs a conversation. + const scale = MAX_BILLABLE_HOURS / (aiHours + manualHours); + aiHours = roundHalf(aiHours * scale); + manualHours = roundHalf(manualHours * scale); + cappedForScoping = true; + } + + const totalHours = aiHours + manualHours; + const amountUsd = Math.round((totalHours * HOURLY_RATE_USD) / 100) * 100; + + const drivers = Array.from(byLabel.values()) + .map((d) => ({ + ...d, + aiHours: roundHalf(d.aiHours), + manualHours: roundHalf(d.manualHours), + })) + .sort((a, b) => b.aiHours + b.manualHours - (a.aiHours + a.manualHours)); + + return { + rateUsd: HOURLY_RATE_USD, + targetScore: TARGET_SCORE, + aiHours, + manualHours, + totalHours, + amountUsd, + issueCount: billable.length, + drivers, + cappedForScoping, + }; +} + +/** + * Fallback when only summary counts are on hand (e.g. a consolidated scan-run + * PDF assembled from rows that carry counts but not findings). Uses the + * blended default effort, so it lands in the same ballpark without pretending + * to know which categories are involved. + */ +export function quoteFromCounts(input: { warn: number; fail: number }): Quote { + const synthetic: Finding[] = [ + ...Array.from({ length: Math.max(0, input.fail) }, (_, i) => ({ + section: "Summary", + check_key: `synthetic.fail_${i}`, + status: "fail" as const, + title: "", + priority: 2 as const, + })), + ...Array.from({ length: Math.max(0, input.warn) }, (_, i) => ({ + section: "Summary", + check_key: `synthetic.warn_${i}`, + status: "warn" as const, + title: "", + priority: 3 as const, + })), + ]; + return quoteFromFindings(synthetic); +} + +export function formatUsd(amount: number): string { + return `$${amount.toLocaleString("en-US")}`; +} + +/** Hours, printed without a trailing ".0". */ +export function formatHours(h: number): string { + return `${Number.isInteger(h) ? h : h.toFixed(1)}h`; +} diff --git a/lib/markdown.ts b/lib/markdown.ts index 6385971a..ffc2336d 100644 --- a/lib/markdown.ts +++ b/lib/markdown.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import { Marked } from "marked"; +import { formatHours, formatUsd, type Quote } from "./audit/quote"; // Server-side Markdown → HTML. // @@ -141,6 +142,11 @@ export function htmlDocument(input: { score?: number; generatedAt?: string; }; + /** + * Remediation offer rendered on the cover. Priced from the report's own + * findings by lib/audit/quote.ts — see quoteFromFindings. + */ + quote?: Quote; }): string { const meta = input.meta ?? {}; const score = meta.score; @@ -243,6 +249,50 @@ export function htmlDocument(input: { text-transform: uppercase; margin-top: 4px; } + /* Remediation offer — the cover's call to action. */ + .quote { + margin-top: 0.42in; + border: 1.5pt solid #059669; + border-radius: 6pt; + background: #f0fdf9; + padding: 14pt 16pt; + page-break-inside: avoid; + } + .quote .kicker { + font-size: 8.5pt; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #047857; + } + .quote .amount { + font-size: 26pt; + font-weight: 800; + line-height: 1.1; + letter-spacing: -0.02em; + color: #064e3b; + margin-top: 3pt; + } + .quote .amount .unit { font-size: 12pt; font-weight: 600; color: #047857; } + .quote .promise { font-size: 11pt; color: #14532d; margin-top: 4pt; } + .quote .split { + display: flex; + gap: 18pt; + margin-top: 11pt; + padding-top: 10pt; + border-top: 1px solid #a7f3d0; + } + .quote .split div { font-size: 10pt; color: #14532d; } + .quote .split .h { + display: block; + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 13pt; + font-weight: 700; + color: #064e3b; + } + .quote .drivers { font-size: 9pt; color: #166534; margin-top: 9pt; line-height: 1.45; } + .quote .fine { font-size: 8.5pt; color: #3f6212; margin-top: 8pt; } + .meta-list { font-size: 10.5pt; color: #334155; } .meta-list dt { color: #64748b; font-size: 9pt; text-transform: uppercase; letter-spacing: 0.1em; } .meta-list dd { margin: 2px 0 12px; padding: 0; font-weight: 600; } @@ -339,6 +389,7 @@ export function htmlDocument(input: {
Audit by
CrawlProof · crawlproof.com
+ ${input.quote ? quoteBlock(input.quote, meta.target) : ""} ${decorateStatusPills(input.bodyHtml)} @@ -347,6 +398,46 @@ export function htmlDocument(input: { `; } +// The cover's "we'll fix this for you" offer. Every number here comes from the +// findings in the report it's printed on, so the quote and the evidence for it +// travel together. +function quoteBlock(q: Quote, target?: string): string { + const host = (() => { + if (!target) return "your site"; + try { + return new URL(target).hostname; + } catch { + return target; + } + })(); + + const drivers = q.drivers + .slice(0, 4) + .map((d) => `${escapeHtml(d.label)} ${formatHours(d.aiHours + d.manualHours)}`) + .join(" · "); + + return `
+
Want us to fix this for you?
+
${formatUsd(q.amountUsd)} USD${q.cappedForScoping ? "+" : ""}
+
+ Estimated ${formatHours(q.totalHours)} at ${formatUsd(q.rateUsd)}/hour to take + ${escapeHtml(host)} to a ${q.targetScore}%+ score across the board. +
+
+
${formatHours(q.aiHours)}AI-assisted automation
+
${formatHours(q.manualHours)}Manual engineering
+
+ ${drivers ? `
Where the time goes: ${drivers}
` : ""} +
+ Scoped from the ${q.issueCount} open issue${q.issueCount === 1 ? "" : "s"} in this report${ + q.cappedForScoping + ? ", which exceeds a standard remediation — the final figure is confirmed after a scoping call" + : "" + }. Fixed fee, quoted before work starts. Reply to this report or email hello@crawlproof.com. +
+
`; +} + function escapeHtml(s: string): string { return s .replace(/&/g, "&") diff --git a/tests/quote.test.ts b/tests/quote.test.ts new file mode 100644 index 00000000..1cd27c87 --- /dev/null +++ b/tests/quote.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect } from "vitest"; +import { + HOURLY_RATE_USD, + TARGET_SCORE, + formatHours, + formatUsd, + quoteFromCounts, + quoteFromFindings, +} from "@/lib/audit/quote"; +import type { Finding } from "@/lib/audit/types"; + +function f(check_key: string, status: Finding["status"] = "fail"): Finding { + return { section: "S", check_key, status, title: check_key, priority: 3 }; +} + +describe("quote: pricing basics", () => { + it("bills at $100/hour and promises 90%+", () => { + expect(HOURLY_RATE_USD).toBe(100); + expect(TARGET_SCORE).toBe(90); + const q = quoteFromFindings([f("homepage.title")]); + expect(q.rateUsd).toBe(100); + expect(q.targetScore).toBe(90); + expect(q.amountUsd).toBe(Math.round((q.totalHours * 100) / 100) * 100); + }); + + it("always splits hours into AI-assisted and manual", () => { + const q = quoteFromFindings([f("homepage.title"), f("dns.spf")]); + expect(q.aiHours).toBeGreaterThan(0); + expect(q.manualHours).toBeGreaterThan(0); + expect(q.totalHours).toBeCloseTo(q.aiHours + q.manualHours, 5); + }); + + it("quotes baseline overhead even for a nearly clean site", () => { + const clean = quoteFromFindings([f("homepage.title", "warn")]); + // Intake + plan + verification re-scan still has to be paid for. + expect(clean.totalHours).toBeGreaterThanOrEqual(4); + expect(clean.amountUsd).toBeGreaterThanOrEqual(400); + }); + + it("ignores passes — a passing check is not work", () => { + const withPasses = quoteFromFindings([ + f("homepage.title"), + f("schema.org", "pass"), + f("meta.viewport", "pass"), + ]); + const withoutPasses = quoteFromFindings([f("homepage.title")]); + expect(withPasses.amountUsd).toBe(withoutPasses.amountUsd); + expect(withPasses.issueCount).toBe(1); + }); + + it("does not bill twice for recommendation and to-do restatements", () => { + const real = quoteFromFindings([f("homepage.title"), f("homepage.description")]); + const padded = quoteFromFindings([ + f("homepage.title"), + f("homepage.description"), + f("rec.homepage.title", "warn"), + f("todo.homepage.title", "warn"), + f("slop.score"), + f("slop.coverage", "warn"), + f("crawl.pages_fetched"), + ]); + expect(padded.amountUsd).toBe(real.amountUsd); + }); + + it("bills one fix once, however many engines flagged it", () => { + const oneEngine = quoteFromFindings([f("homepage.description"), f("schema.org")]); + // Same two defects, reported by four engines in a consolidated scan run. + const fourEngines = quoteFromFindings([ + ...Array.from({ length: 4 }, () => f("homepage.description")), + ...Array.from({ length: 4 }, () => f("schema.org")), + ]); + expect(fourEngines.amountUsd).toBe(oneEngine.amountUsd); + expect(fourEngines.issueCount).toBe(2); + }); + + it("keeps the worst status when engines disagree", () => { + const asWarn = quoteFromFindings([f("homepage.title", "warn")]); + const mixed = quoteFromFindings([f("homepage.title", "warn"), f("homepage.title", "fail")]); + expect(mixed.totalHours).toBeGreaterThan(asWarn.totalHours); + expect(mixed.issueCount).toBe(1); + }); + + it("charges a warn less than a fail", () => { + const fail = quoteFromFindings([f("homepage.title", "fail")]); + const warn = quoteFromFindings([f("homepage.title", "warn")]); + expect(warn.totalHours).toBeLessThan(fail.totalHours); + }); + + it("scales with the number of issues", () => { + const few = quoteFromFindings([f("meta.a"), f("meta.b")]); + const many = quoteFromFindings( + Array.from({ length: 40 }, (_, i) => f(`meta.x${i}`)), + ); + expect(many.amountUsd).toBeGreaterThan(few.amountUsd * 2); + }); +}); + +describe("quote: AI vs manual classification", () => { + it("treats markup/metadata as mostly AI work", () => { + const q = quoteFromFindings(Array.from({ length: 10 }, (_, i) => f(`meta.tag${i}`))); + expect(q.aiHours).toBeGreaterThan(q.manualHours); + }); + + it("treats DNS and crawler-access records as mostly manual", () => { + const base = quoteFromFindings([]); + const q = quoteFromFindings(Array.from({ length: 10 }, (_, i) => f(`dns.record${i}`))); + // Net of the fixed baseline, DNS work is manual-dominant. + expect(q.manualHours - base.manualHours).toBeGreaterThan(q.aiHours - base.aiHours); + }); + + it("treats original content and positioning as mostly manual", () => { + const base = quoteFromFindings([]); + const q = quoteFromFindings([ + f("content.no_first_party_evidence"), + f("content.thin"), + f("positioning.what"), + f("slop.site.content.near_duplicate"), + ]); + expect(q.manualHours - base.manualHours).toBeGreaterThan(q.aiHours - base.aiHours); + }); + + it("prices a template fix above a single page but below the pages it resolves", () => { + const systemic = quoteFromFindings([f("slop.systemic.design.no_viewport")]); + const onePage = quoteFromFindings([f("slop.page.about")]); + const tenPages = quoteFromFindings( + Array.from({ length: 10 }, (_, i) => f(`slop.page.p${i}`)), + ); + expect(systemic.totalHours).toBeGreaterThan(onePage.totalHours); + expect(systemic.totalHours).toBeLessThan(tenPages.totalHours); + }); + + it("reports drivers, largest first, summing to the quoted hours", () => { + const q = quoteFromFindings([ + f("content.no_first_party_evidence"), + f("content.thin"), + f("meta.title"), + f("dns.spf"), + ]); + expect(q.drivers.length).toBeGreaterThan(1); + const totals = q.drivers.map((d) => d.aiHours + d.manualHours); + expect(totals).toEqual([...totals].sort((a, b) => b - a)); + expect(q.drivers.some((d) => d.label === "Original content & positioning")).toBe(true); + expect(q.drivers.some((d) => d.label === "DNS, robots & crawler access")).toBe(true); + }); +}); + +describe("quote: guardrails", () => { + it("caps a catastrophic report and flags it for manual scoping", () => { + const q = quoteFromFindings( + Array.from({ length: 600 }, (_, i) => f(`content.no_first_party_evidence${i}`)), + ); + expect(q.cappedForScoping).toBe(true); + expect(q.totalHours).toBeLessThanOrEqual(120); + expect(q.amountUsd).toBeLessThanOrEqual(12_000); + }); + + it("does not flag a normal report for scoping", () => { + const q = quoteFromFindings(Array.from({ length: 25 }, (_, i) => f(`meta.x${i}`))); + expect(q.cappedForScoping).toBe(false); + }); + + it("rounds to half hours and whole hundreds of dollars", () => { + const q = quoteFromFindings(Array.from({ length: 7 }, (_, i) => f(`meta.x${i}`, "warn"))); + expect(q.aiHours * 2).toBe(Math.round(q.aiHours * 2)); + expect(q.manualHours * 2).toBe(Math.round(q.manualHours * 2)); + expect(q.amountUsd % 100).toBe(0); + }); + + it("falls back to summary counts when findings are unavailable", () => { + const q = quoteFromCounts({ warn: 10, fail: 5 }); + expect(q.issueCount).toBe(15); + expect(q.amountUsd).toBeGreaterThan(0); + expect(q.aiHours).toBeGreaterThan(0); + expect(q.manualHours).toBeGreaterThan(0); + }); + + it("handles an empty report without dividing by zero", () => { + const q = quoteFromFindings([]); + expect(q.issueCount).toBe(0); + expect(Number.isFinite(q.amountUsd)).toBe(true); + expect(q.amountUsd).toBeGreaterThan(0); // baseline only + }); +}); + +describe("quote: formatting", () => { + it("formats money with thousands separators", () => { + expect(formatUsd(4000)).toBe("$4,000"); + expect(formatUsd(700)).toBe("$700"); + }); + + it("drops the decimal on whole hours", () => { + expect(formatHours(40)).toBe("40h"); + expect(formatHours(12.5)).toBe("12.5h"); + }); +}); diff --git a/worker/index.ts b/worker/index.ts index d04ea005..0639182a 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -23,6 +23,8 @@ import { markdownToHtml, htmlDocument } from "../lib/markdown"; import { auditReadyEmailHtml, scanRunSummaryEmailHtml, type SummaryEngineRow } from "../lib/email"; import { ENGINES, type Engine } from "../lib/credits"; import { buildScanRunMarkdown } from "../lib/audit/summary-markdown"; +import { quoteFromFindings, type Quote } from "../lib/audit/quote"; +import type { Finding } from "../lib/audit/types"; import { auditStuckAfterMinutes, auditStuckAfterMs } from "../lib/audit/timeouts"; import OpenAI from "openai"; import { crawlSitemap } from "../lib/lx/sitemapCrawl"; @@ -692,6 +694,23 @@ async function maybeSendEmail(input: { await sendSummaryEmail({ scanRunId, projectId, pdfEmail, targetUrl, rows }); } +// Price the "we'll fix this for you" cover offer from the findings we just +// wrote. Best-effort — a failed lookup drops the offer rather than the PDF. +async function quoteForAudits(auditIds: string[]): Promise { + if (auditIds.length === 0) return undefined; + try { + const { data, error } = await supabase + .from("audit_findings") + .select("section, check_key, status, title, priority") + .in("audit_id", auditIds); + if (error || !data || data.length === 0) return undefined; + return quoteFromFindings(data as unknown as Finding[]); + } catch (err) { + console.warn("[worker] quote lookup failed; PDF will omit the offer", err); + return undefined; + } +} + async function sendPerEngineEmail(input: { auditId: string; pdfEmail: string; @@ -712,6 +731,7 @@ async function sendPerEngineEmail(input: { score, generatedAt: new Date().toISOString(), }, + quote: await quoteForAudits([auditId]), }); const pdf = await renderPdfFromHtml(html); const filename = `crawlproof-${new URL(targetUrl).hostname}-${auditId.slice(0, 8)}.pdf`; @@ -788,6 +808,8 @@ async function sendSummaryEmail(input: { score: avgScore ?? undefined, generatedAt: new Date().toISOString(), }, + // Consolidated PDF quotes the whole job — every engine in the run. + quote: await quoteForAudits(rows.filter((r) => r.status === "complete").map((r) => r.id)), }); const pdf = await renderPdfFromHtml(combinedHtml); const filename = `crawlproof-${host}-${scanRunId.slice(0, 8)}.pdf`; @@ -844,6 +866,7 @@ const server = http.createServer(async (req, res) => { title?: string; target?: string; score?: number; + quote?: Quote; }; let pdf: Buffer; if (payload.html) { @@ -860,6 +883,7 @@ const server = http.createServer(async (req, res) => { score: payload.score, generatedAt: new Date().toISOString(), }, + quote: payload.quote, }); pdf = await renderPdfFromHtml(html); } else if (payload.token) {