Skip to content

Commit ac34dda

Browse files
ralyodioclaude
andcommitted
feat: consolidated report.md / pdf + Run Now button
Three connected changes so the share-token document, the owner-side PDF download, and the worker's email PDF all serve the same thing for a multi-engine scan run: one document with an executive summary followed by each engine's full report. Single document, designed for pasting into another LLM. - lib/audit/summary-markdown.ts: extracted from worker/index.ts as the single source of truth for the consolidated layout. Worker now imports it instead of carrying its own copy. - /r/<token>/report.md: if the token belongs to an audit whose scan_run has ≥2 audits, response is the consolidated Markdown. Solo runs still return the single audit's markdown. - /api/audits/<id>/pdf: same logic on the owner-side PDF download. Score in the PDF metadata becomes the average across engines for multi-engine runs. - New RunNowButton on the project page — one-click trigger using the project's saved engine list, outside the scheduled cadence. Paid engines route through a confirm step so credits don't burn on accidental clicks; free runs fire immediately. Redirects to the scan-run page so the user lands on the live progress view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 49fdef3 commit ac34dda

6 files changed

Lines changed: 265 additions & 91 deletions

File tree

‎app/(app)/projects/[id]/page.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ScoreBadge } from "@/components/score-badge";
55
import { EnginesPanel } from "@/components/engines-panel";
66
import { ScheduleToggle } from "@/components/schedule-toggle";
77
import { ProjectStatusControls } from "@/components/project-status-controls";
8+
import { RunNowButton } from "@/components/run-now-button";
89
import type { ProjectStatus } from "@/app/actions/projects";
910
import type { Engine } from "@/lib/credits";
1011
import { ScoreTrend } from "@/components/charts/score-trend";
@@ -109,6 +110,11 @@ export default async function ProjectPage({
109110
status={(project.status ?? "active") as ProjectStatus}
110111
/>
111112
<ScheduleToggle projectId={project.id} current={project.schedule} />
113+
<RunNowButton
114+
projectId={project.id}
115+
url={project.url}
116+
engines={(project.engines ?? ["rule"]) as Engine[]}
117+
/>
112118
{last && prev && (
113119
<Link href={`/audits/${last.id}?diff=${prev.id}`} className="btn">
114120
Diff vs previous

‎app/api/audits/[id]/pdf/route.ts‎

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import { NextResponse } from "next/server";
22
import { createClient } from "@/lib/supabase/server";
33
import { env } from "@/lib/env";
4+
import {
5+
buildScanRunMarkdown,
6+
type SummaryRow,
7+
} from "@/lib/audit/summary-markdown";
48

59
export const runtime = "nodejs";
610

11+
// Owner PDF download. For multi-engine scan runs the response is the
12+
// consolidated PDF (executive summary + every engine's full report).
13+
// Solo runs return the single audit's PDF as before.
714
export async function GET(
815
_req: Request,
916
{ params }: { params: Promise<{ id: string }> },
@@ -15,10 +22,11 @@ export async function GET(
1522
} = await supabase.auth.getUser();
1623
if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
1724

18-
// PDF export is included with every scan — the credit already paid for it.
1925
const { data: audit } = await supabase
2026
.from("audits")
21-
.select("share_token, owner_id, status, report_markdown, target_url, score")
27+
.select(
28+
"id, share_token, owner_id, status, report_markdown, target_url, score, scan_run_id",
29+
)
2230
.eq("id", id)
2331
.maybeSingle();
2432
if (!audit || audit.owner_id !== user.id) {
@@ -30,20 +38,48 @@ export async function GET(
3038
if (!audit.report_markdown) {
3139
return NextResponse.json({ error: "Report markdown missing." }, { status: 500 });
3240
}
33-
3441
if (!env.workerUrl) {
3542
return NextResponse.json({ error: "Worker not configured" }, { status: 500 });
3643
}
3744

38-
// Worker pandoc-converts the markdown and renders the result as PDF.
45+
let markdown = audit.report_markdown;
46+
let title = `AEO Audit — ${new URL(audit.target_url).hostname}`;
47+
let scoreForPdf: number | null = audit.score;
48+
49+
if (audit.scan_run_id) {
50+
const { data: siblings } = await supabase
51+
.from("audits")
52+
.select(
53+
"id, engine, status, score, share_token, summary, report_markdown, failed_reason, created_at",
54+
)
55+
.eq("scan_run_id", audit.scan_run_id)
56+
.eq("owner_id", user.id)
57+
.order("created_at", { ascending: true });
58+
const rows = (siblings ?? []) as SummaryRow[];
59+
if (rows.length > 1) {
60+
markdown = buildScanRunMarkdown({ targetUrl: audit.target_url, rows });
61+
const completed = rows.filter(
62+
(r) => r.status === "complete" && r.score !== null,
63+
);
64+
scoreForPdf =
65+
completed.length > 0
66+
? Math.round(
67+
completed.reduce((s, r) => s + (r.score ?? 0), 0) /
68+
completed.length,
69+
)
70+
: null;
71+
title = `AEO Audit — ${new URL(audit.target_url).hostname} (${rows.length} engines)`;
72+
}
73+
}
74+
3975
const workerRes = await fetch(`${env.workerUrl}/pdf`, {
4076
method: "POST",
4177
headers: { "content-type": "application/json", "x-worker-secret": env.workerSecret },
4278
body: JSON.stringify({
43-
markdown: audit.report_markdown,
44-
title: `AEO Audit — ${new URL(audit.target_url).hostname}`,
79+
markdown,
80+
title,
4581
target: audit.target_url,
46-
score: audit.score,
82+
score: scoreForPdf,
4783
}),
4884
});
4985
if (!workerRes.ok) {

‎app/r/[token]/report.md/route.ts‎

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,52 @@
11
import { serviceClient } from "@/lib/supabase/service";
2+
import { buildScanRunMarkdown, type SummaryRow } from "@/lib/audit/summary-markdown";
23

34
export const dynamic = "force-dynamic";
45

6+
// Serves the share-token's report as Markdown. For multi-engine scan runs
7+
// (≥ 2 audits sharing the same scan_run_id) the response is a consolidated
8+
// document — executive summary + each engine's full report — designed for
9+
// pasting into another LLM. Solo runs return the single audit's markdown.
510
export async function GET(
611
_req: Request,
712
{ params }: { params: Promise<{ token: string }> },
813
) {
914
const { token } = await params;
1015
const svc = serviceClient();
11-
const { data } = await svc.rpc("get_public_audit", { token });
12-
const audit = (data as Array<{ report_markdown: string | null }> | null)?.[0];
13-
if (!audit) return new Response("Not found", { status: 404 });
14-
const md = audit.report_markdown;
15-
if (!md) return new Response("Report not ready yet.", { status: 425 });
16-
return new Response(md, {
16+
17+
// Look up the audit by share_token. We need the scan_run_id and
18+
// target_url to decide whether to stitch siblings into one document.
19+
const { data: own } = await svc
20+
.from("audits")
21+
.select("id, scan_run_id, target_url, report_markdown, status")
22+
.eq("share_token", token)
23+
.maybeSingle();
24+
if (!own) return new Response("Not found", { status: 404 });
25+
if (own.status !== "complete" || !own.report_markdown) {
26+
return new Response("Report not ready yet.", { status: 425 });
27+
}
28+
29+
if (own.scan_run_id) {
30+
const { data: siblings } = await svc
31+
.from("audits")
32+
.select(
33+
"id, engine, status, score, share_token, summary, report_markdown, failed_reason, created_at",
34+
)
35+
.eq("scan_run_id", own.scan_run_id)
36+
.order("created_at", { ascending: true });
37+
const rows = (siblings ?? []) as SummaryRow[];
38+
if (rows.length > 1) {
39+
const md = buildScanRunMarkdown({ targetUrl: own.target_url, rows });
40+
return new Response(md, {
41+
headers: {
42+
"content-type": "text/markdown; charset=utf-8",
43+
"cache-control": "no-store",
44+
},
45+
});
46+
}
47+
}
48+
49+
return new Response(own.report_markdown, {
1750
headers: {
1851
"content-type": "text/markdown; charset=utf-8",
1952
"cache-control": "no-store",

‎components/run-now-button.tsx‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"use client";
2+
3+
import { useState, useTransition } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { selectionCost, type Engine } from "@/lib/credits";
6+
import { runScanForProject } from "@/app/actions/runAudit";
7+
8+
// One-click "Run now" — fires a scan using the project's saved engine
9+
// list outside the scheduled cadence. For paid engines we show a confirm
10+
// step so the user can't burn credits with an accidental click.
11+
export function RunNowButton({
12+
projectId,
13+
url,
14+
engines,
15+
}: {
16+
projectId: string;
17+
url: string;
18+
engines: Engine[];
19+
}) {
20+
const router = useRouter();
21+
const [pending, start] = useTransition();
22+
const [error, setError] = useState<string | null>(null);
23+
const [confirming, setConfirming] = useState(false);
24+
25+
const cost = selectionCost(engines);
26+
27+
function onClick() {
28+
setError(null);
29+
if (!confirming && cost > 0) {
30+
setConfirming(true);
31+
return;
32+
}
33+
start(async () => {
34+
const res = await runScanForProject({ projectId, url, engines });
35+
if (!res.ok) {
36+
setError(res.error);
37+
setConfirming(false);
38+
return;
39+
}
40+
router.push(`/projects/${projectId}/runs/${res.scanRunId}`);
41+
});
42+
}
43+
44+
return (
45+
<div className="flex flex-wrap items-center gap-2">
46+
<button
47+
className="btn btn-primary"
48+
disabled={pending || engines.length === 0}
49+
onClick={onClick}
50+
title="Run a one-off scan now, outside the scheduled cadence"
51+
>
52+
{pending
53+
? "Starting…"
54+
: confirming
55+
? `Confirm — spend ${cost} credit${cost === 1 ? "" : "s"}`
56+
: cost === 0
57+
? "Run now (free)"
58+
: `Run now · ${cost} credit${cost === 1 ? "" : "s"}`}
59+
</button>
60+
{confirming && !pending && (
61+
<button className="btn" onClick={() => setConfirming(false)}>
62+
Cancel
63+
</button>
64+
)}
65+
{error && (
66+
<span className="text-sm text-[var(--color-fail)]">{error}</span>
67+
)}
68+
</div>
69+
);
70+
}

‎lib/audit/summary-markdown.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { ENGINES, type Engine } from "@/lib/credits";
2+
3+
export type SummaryRow = {
4+
id: string;
5+
engine: string;
6+
status: string;
7+
score: number | null;
8+
share_token: string | null;
9+
summary: { pass?: number; warn?: number; fail?: number } | null;
10+
report_markdown: string | null;
11+
failed_reason: string | null;
12+
created_at: string;
13+
};
14+
15+
// Build the canonical "executive summary + per-engine sections" Markdown
16+
// for a multi-engine scan run. Used by the worker (for the summary PDF
17+
// it attaches to email) and by the report.md / pdf routes when a share
18+
// token belongs to a multi-engine run. Single source of truth so all
19+
// three surfaces stay in sync.
20+
export function buildScanRunMarkdown(input: {
21+
targetUrl: string;
22+
rows: SummaryRow[];
23+
}): string {
24+
const { targetUrl, rows } = input;
25+
const completed = rows.filter(
26+
(r) => r.status === "complete" && r.score !== null,
27+
);
28+
const avgScore =
29+
completed.length > 0
30+
? Math.round(
31+
completed.reduce((s, r) => s + (r.score ?? 0), 0) / completed.length,
32+
)
33+
: null;
34+
35+
const host = (() => {
36+
try { return new URL(targetUrl).hostname; } catch { return targetUrl; }
37+
})();
38+
const generated = new Date().toISOString();
39+
40+
const tableRows = rows
41+
.map((r) => {
42+
const meta = ENGINES[r.engine as Engine];
43+
const label = meta?.label ?? r.engine;
44+
const scoreCell =
45+
r.status === "complete" && r.score !== null ? `${r.score}/100` : r.status;
46+
const p = r.summary?.pass ?? 0;
47+
const w = r.summary?.warn ?? 0;
48+
const f = r.summary?.fail ?? 0;
49+
return `| ${label} | ${scoreCell} | ${p} | ${w} | ${f} |`;
50+
})
51+
.join("\n");
52+
53+
const exec = [
54+
`# AEO Audit — ${host}`,
55+
``,
56+
`**Target:** ${targetUrl}`,
57+
``,
58+
`**Generated:** ${generated}`,
59+
``,
60+
`**Engines:** ${rows.length}${avgScore !== null ? ` · **Average score:** ${avgScore}/100` : ""}`,
61+
``,
62+
`## Executive Summary`,
63+
``,
64+
`| Engine | Score | Pass | Warn | Fail |`,
65+
`|---|---:|---:|---:|---:|`,
66+
tableRows,
67+
``,
68+
avgScore !== null
69+
? `Average score: **${avgScore}/100** across ${rows.length} engine${rows.length === 1 ? "" : "s"}.`
70+
: `${rows.length} engine${rows.length === 1 ? "" : "s"} run.`,
71+
``,
72+
`---`,
73+
``,
74+
].join("\n");
75+
76+
const perEngine = rows
77+
.map((r) => {
78+
const meta = ENGINES[r.engine as Engine];
79+
const label = meta?.label ?? r.engine;
80+
if (r.status === "failed") {
81+
return [
82+
`## Engine: ${label}`,
83+
``,
84+
`_Engine failed: ${r.failed_reason ?? "unknown reason"}_`,
85+
``,
86+
`---`,
87+
``,
88+
].join("\n");
89+
}
90+
if (!r.report_markdown) {
91+
return [`## Engine: ${label}`, ``, `_Report unavailable._`, ``, `---`, ``].join("\n");
92+
}
93+
return [
94+
`## Engine: ${label}`,
95+
``,
96+
r.report_markdown,
97+
``,
98+
`---`,
99+
``,
100+
].join("\n");
101+
})
102+
.join("\n");
103+
104+
return exec + perEngine;
105+
}

0 commit comments

Comments
 (0)