88 lines
3.1 KiB
TypeScript
88 lines
3.1 KiB
TypeScript
|
|
/**
|
||
|
|
* /api/admin/investigate/test — admin-only trigger for a synthetic
|
||
|
|
* investigation job. Gated by middleware (W0-F1: /api/admin/* → 404 unless
|
||
|
|
* profile.role==='admin').
|
||
|
|
*
|
||
|
|
* POST { kind?: 'evidence_chain', doc_id, chunks?: string[] } — INSERT a row
|
||
|
|
* in public.investigation_jobs; the disclosure-investigator container's
|
||
|
|
* LISTEN handler picks it up, dispatches Locard, and updates status.
|
||
|
|
* GET ?job_id=... — read the latest state for polling.
|
||
|
|
*/
|
||
|
|
import { NextResponse } from "next/server";
|
||
|
|
import { pgQuery } from "@/lib/retrieval/db";
|
||
|
|
import { withRequest } from "@/lib/logger";
|
||
|
|
|
||
|
|
export const runtime = "nodejs";
|
||
|
|
export const dynamic = "force-dynamic";
|
||
|
|
|
||
|
|
interface JobRow {
|
||
|
|
job_id: string;
|
||
|
|
kind: string;
|
||
|
|
payload: unknown;
|
||
|
|
status: string;
|
||
|
|
worker_id: string | null;
|
||
|
|
started_at: string | null;
|
||
|
|
finished_at: string | null;
|
||
|
|
outputs: unknown;
|
||
|
|
error: string | null;
|
||
|
|
created_at: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function POST(request: Request) {
|
||
|
|
const log = withRequest(request);
|
||
|
|
let body: { kind?: string; doc_id?: string; chunks?: string[]; claim?: string };
|
||
|
|
try {
|
||
|
|
body = await request.json();
|
||
|
|
} catch {
|
||
|
|
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
|
||
|
|
}
|
||
|
|
const kind = body.kind ?? "evidence_chain";
|
||
|
|
const doc_id = body.doc_id;
|
||
|
|
if (!doc_id) return NextResponse.json({ error: "doc_id_required" }, { status: 400 });
|
||
|
|
|
||
|
|
const payload: Record<string, unknown> = { doc_id };
|
||
|
|
if (body.chunks?.length) payload.chunks = body.chunks;
|
||
|
|
if (body.claim) payload.claim = body.claim;
|
||
|
|
|
||
|
|
// Triggered_by carries the admin email so the audit ties back to a person.
|
||
|
|
// Middleware already validated the admin role; we just label here.
|
||
|
|
const triggered_by = `user:${request.headers.get("x-user-email") ?? "admin"}`;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const rows = await pgQuery<{ job_id: string }>(
|
||
|
|
`INSERT INTO public.investigation_jobs (kind, payload, triggered_by, status)
|
||
|
|
VALUES ($1, $2::jsonb, $3, 'queued')
|
||
|
|
RETURNING job_id`,
|
||
|
|
[kind, JSON.stringify(payload), triggered_by],
|
||
|
|
);
|
||
|
|
const job_id = rows[0]?.job_id;
|
||
|
|
log.info({ event: "investigation_job_created", kind, doc_id, job_id }, "investigation job queued");
|
||
|
|
return NextResponse.json({
|
||
|
|
job_id,
|
||
|
|
kind,
|
||
|
|
status: "queued",
|
||
|
|
poll_url: `/api/admin/investigate/test?job_id=${job_id}`,
|
||
|
|
});
|
||
|
|
} catch (e) {
|
||
|
|
return NextResponse.json({ error: "db_unavailable", message: (e as Error).message }, { status: 503 });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function GET(request: Request) {
|
||
|
|
const url = new URL(request.url);
|
||
|
|
const job_id = url.searchParams.get("job_id");
|
||
|
|
if (!job_id) return NextResponse.json({ error: "job_id_required" }, { status: 400 });
|
||
|
|
try {
|
||
|
|
const rows = await pgQuery<JobRow>(
|
||
|
|
`SELECT job_id, kind, payload, status, worker_id, started_at, finished_at,
|
||
|
|
outputs, error, created_at
|
||
|
|
FROM public.investigation_jobs WHERE job_id = $1`,
|
||
|
|
[job_id],
|
||
|
|
);
|
||
|
|
if (rows.length === 0) return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||
|
|
return NextResponse.json(rows[0]);
|
||
|
|
} catch (e) {
|
||
|
|
return NextResponse.json({ error: "db_unavailable", message: (e as Error).message }, { status: 503 });
|
||
|
|
}
|
||
|
|
}
|