Closes the gap between "brief exists" and "report ships". The Phase A
placeholders for Home and Reports/detail are now real, and the brief detail
page can actually start a pipeline run.
Server (no schema changes — reports table already existed):
- db/reports.ts: createReport, getReport, getReportWithBrief, listReportsForTeam,
updateReportStatus, finishReport, logCostEvent (atomically updates the
reports row's running totals), listCostEvents.
- routes/reports.ts: GET /api/reports (active team), GET /api/reports/:id
(with cost_events), POST /api/briefs/:id/run that
1. authorises (editor+ on the brief's team),
2. creates a reports row (status=pending),
3. spawns the pipeline as a detached child running
`tsx pipeline/cli.ts all --report <brief_id> --run-id <reports.id>`,
4. returns the new report id.
Singleton flag prevents two concurrent runs (mirrors V1).
Pipeline:
- cli.ts: new --run-id flag. New `all` command drives every stage in order
via a withStage() helper that updates reports.status / current_stage at
each step. Cost callbacks now ALSO write to cost_events when run-id is
set, tagged with the current stage. main()'s catch handler calls
finishReport(runId, 'failed', err.message) so the UI doesn't poll forever
on a crash.
Client:
- api/reports.ts: useRecentReports, useReport (auto-polls every 3s while
status is non-terminal), useRunPipeline.
- routes/home.tsx: real recent-reports list — status pill, brief client +
business question, cost split, relative time.
- routes/reports/detail.tsx: full run page — header with status pill,
10-step pipeline progress with current-stage pulse, error block on
failure, three-tile cost summary (total / apify / claude), cost-event
log (most recent first, scrollable, sticky header), "Open dashboard"
+ "Download HTML bundle" actions when the run completes.
- routes/briefs/detail.tsx: Run pipeline button is now functional for
editors+, with a confirm dialog (warns about Apify/Claude spend),
navigates to the new /reports/:id on success, surfaces 409 if another
run is in flight.
62/62 unit tests still pass. Typecheck + vite build green; bundle 269 kB.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
168 lines
4.5 KiB
TypeScript
168 lines
4.5 KiB
TypeScript
import { sql } from './client.js';
|
|
|
|
export type ReportStatus =
|
|
| 'pending' | 'seeds' | 'pass1' | 'select' | 'pass2' | 'validate'
|
|
| 'analyse' | 'insights' | 'trends' | 'qa' | 'build'
|
|
| 'completed' | 'failed';
|
|
|
|
export interface ReportRow {
|
|
id: string;
|
|
brief_id: string;
|
|
team_id: string;
|
|
triggered_by: string;
|
|
status: ReportStatus;
|
|
current_stage: number;
|
|
started_at: Date;
|
|
finished_at: Date | null;
|
|
apify_cost_usd: string; // postgres NUMERIC comes through as string
|
|
claude_cost_usd: string;
|
|
total_cost_usd: string;
|
|
fs_root: string;
|
|
manifest_passed_at: Date | null;
|
|
error_message: string | null;
|
|
}
|
|
|
|
export interface ReportWithBrief extends ReportRow {
|
|
brief_client_name: string;
|
|
brief_slug: string;
|
|
brief_business_question: string;
|
|
}
|
|
|
|
export interface CostEventRow {
|
|
id: number;
|
|
report_id: string;
|
|
created_at: Date;
|
|
stage: number;
|
|
stage_name: string;
|
|
source: 'claude' | 'apify';
|
|
label: string;
|
|
model: string | null;
|
|
input_tokens: number;
|
|
output_tokens: number;
|
|
cost_usd: string;
|
|
metadata: Record<string, unknown> | null;
|
|
}
|
|
|
|
export async function createReport(input: {
|
|
brief_id: string;
|
|
team_id: string;
|
|
triggered_by: string;
|
|
fs_root: string;
|
|
}): Promise<ReportRow> {
|
|
const [row] = await sql<ReportRow[]>`
|
|
INSERT INTO reports (brief_id, team_id, triggered_by, fs_root, status)
|
|
VALUES (${input.brief_id}, ${input.team_id}, ${input.triggered_by}, ${input.fs_root}, 'pending')
|
|
RETURNING *
|
|
`;
|
|
if (!row) throw new Error('createReport: no row returned');
|
|
return row;
|
|
}
|
|
|
|
export async function getReport(id: string): Promise<ReportRow | null> {
|
|
const [row] = await sql<ReportRow[]>`SELECT * FROM reports WHERE id = ${id}`;
|
|
return row ?? null;
|
|
}
|
|
|
|
export async function getReportWithBrief(id: string): Promise<ReportWithBrief | null> {
|
|
const [row] = await sql<ReportWithBrief[]>`
|
|
SELECT r.*,
|
|
b.client_name AS brief_client_name,
|
|
b.slug AS brief_slug,
|
|
b.business_question AS brief_business_question
|
|
FROM reports r
|
|
JOIN briefs b ON b.id = r.brief_id
|
|
WHERE r.id = ${id}
|
|
`;
|
|
return row ?? null;
|
|
}
|
|
|
|
export async function listReportsForTeam(
|
|
team_id: string,
|
|
limit = 25,
|
|
): Promise<ReportWithBrief[]> {
|
|
return sql<ReportWithBrief[]>`
|
|
SELECT r.*,
|
|
b.client_name AS brief_client_name,
|
|
b.slug AS brief_slug,
|
|
b.business_question AS brief_business_question
|
|
FROM reports r
|
|
JOIN briefs b ON b.id = r.brief_id
|
|
WHERE r.team_id = ${team_id}
|
|
ORDER BY r.started_at DESC
|
|
LIMIT ${limit}
|
|
`;
|
|
}
|
|
|
|
export async function updateReportStatus(
|
|
id: string,
|
|
status: ReportStatus,
|
|
current_stage: number,
|
|
): Promise<void> {
|
|
await sql`
|
|
UPDATE reports SET status = ${status}, current_stage = ${current_stage}
|
|
WHERE id = ${id}
|
|
`;
|
|
}
|
|
|
|
export async function finishReport(
|
|
id: string,
|
|
status: 'completed' | 'failed',
|
|
error_message?: string,
|
|
): Promise<void> {
|
|
await sql`
|
|
UPDATE reports SET
|
|
status = ${status},
|
|
finished_at = NOW(),
|
|
error_message = ${error_message ?? null}
|
|
WHERE id = ${id}
|
|
`;
|
|
}
|
|
|
|
export async function logCostEvent(input: {
|
|
report_id: string;
|
|
stage: number;
|
|
stage_name: string;
|
|
source: 'claude' | 'apify';
|
|
label: string;
|
|
model?: string;
|
|
input_tokens?: number;
|
|
output_tokens?: number;
|
|
cost_usd: number;
|
|
metadata?: Record<string, unknown>;
|
|
}): Promise<void> {
|
|
await sql`
|
|
INSERT INTO cost_events (
|
|
report_id, stage, stage_name, source, label, model,
|
|
input_tokens, output_tokens, cost_usd, metadata
|
|
) VALUES (
|
|
${input.report_id}, ${input.stage}, ${input.stage_name}, ${input.source}, ${input.label},
|
|
${input.model ?? null},
|
|
${input.input_tokens ?? 0}, ${input.output_tokens ?? 0},
|
|
${input.cost_usd},
|
|
${input.metadata ? sql.json(input.metadata as Parameters<typeof sql.json>[0]) : null}
|
|
)
|
|
`;
|
|
if (input.source === 'claude') {
|
|
await sql`
|
|
UPDATE reports SET
|
|
claude_cost_usd = claude_cost_usd + ${input.cost_usd},
|
|
total_cost_usd = total_cost_usd + ${input.cost_usd}
|
|
WHERE id = ${input.report_id}
|
|
`;
|
|
} else {
|
|
await sql`
|
|
UPDATE reports SET
|
|
apify_cost_usd = apify_cost_usd + ${input.cost_usd},
|
|
total_cost_usd = total_cost_usd + ${input.cost_usd}
|
|
WHERE id = ${input.report_id}
|
|
`;
|
|
}
|
|
}
|
|
|
|
export async function listCostEvents(report_id: string): Promise<CostEventRow[]> {
|
|
return sql<CostEventRow[]>`
|
|
SELECT * FROM cost_events WHERE report_id = ${report_id}
|
|
ORDER BY created_at ASC
|
|
LIMIT 1000
|
|
`;
|
|
}
|