social-reporting-tool/v2/server/db/reports.ts
DJP a829983bb9 Brief detail: surface this brief's report runs so status survives navigation
When you navigated away from a brief and came back, there was no indication
that a pipeline had already run for it — the page just showed the brief
fields and a "Run pipeline" button, making completed/in-flight runs invisible
without first hitting Home.

Now the brief detail page renders a "Reports for this brief" section listing
every run for the brief — status pill, run id, total cost, started/finished
relative timestamps, click-through to the run page. Auto-refreshes every 3s
while any run is non-terminal so an in-flight pipeline shows live progress
even when the user navigated to the brief instead of the report page.

Server:
- db/reports.ts: listReportsForBrief(brief_id, limit).
- routes/reports.ts: handleListReportsForBrief.
- index.ts: GET /api/briefs/:id/reports.

Client:
- api/reports.ts: useReportsForBrief hook with conditional polling.
- routes/briefs/detail.tsx: BriefReports section with status pills, in-flight
  shortcut link, empty state when no runs exist yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:22:38 -04:00

182 lines
4.9 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 listReportsForBrief(brief_id: string, limit = 50): 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.brief_id = ${brief_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
`;
}