/** * EvidenceController — read-only endpoints for evidence, files, and facts. * * Routes: * GET /api/files/:key — stream evidence file bytes. * GET /api/missions/:id/evidence — list evidence items with parsed outputs + claims. * GET /api/missions/:id/facts — return mission.facts jsonb. * POST /api/missions/:id/facts/confirm — user confirms extracted facts. */ import { Body, Controller, Get, HttpStatus, Inject, NotFoundException, Param, Post, Res, UseGuards, ForbiddenException, } from "@nestjs/common"; import type { FastifyReply } from "fastify"; import { desc, eq, and } from "drizzle-orm"; import { type Database, missions, missionEvents, missionStatusSnapshots, files, evidenceItems, evidenceParsedOutputs, evidenceClaims, } from "@courtmitra/db"; import type { StoragePort } from "@courtmitra/ports"; import { ConfirmFactsInput, STATE_RANK, type ParsedEvidenceDto, type EvidenceClaimDto } from "@courtmitra/contracts"; import { parseOrThrow } from "../common/zod-validation.pipe.js"; import { DB } from "../db/db.module.js"; import { STORAGE } from "../conversations/conversations.service.js"; import { WorkflowService } from "../workflow/inngest.module.js"; import { CurrentUser } from "../auth/current-user.decorator.js"; import { ClerkAuthGuard } from "../auth/clerk-auth.guard.js"; @UseGuards(ClerkAuthGuard) @Controller() export class EvidenceController { constructor( @Inject(DB) private readonly db: Database, @Inject(STORAGE) private readonly storage: StoragePort, @Inject(WorkflowService) private readonly workflow: WorkflowService, ) {} /** Verify the mission belongs to the requesting user. */ private async assertMissionOwnership(missionId: string, userId: string) { const [missionRow] = await this.db .select({ userId: missions.userId }) .from(missions) .where(eq(missions.id, missionId)) .limit(1); if (!missionRow) throw new NotFoundException(`Mission not found: ${missionId}`); if (missionRow.userId !== userId) throw new ForbiddenException("You do not own this mission"); } // ------------------------------------------------------------------------- // File serving // ------------------------------------------------------------------------- @Get("files/:key") async serveFile( @Param("key") key: string, @Res() res: FastifyReply, ) { const fileRows = await this.db .select({ mimeType: files.mimeType }) .from(files) .where(eq(files.storageKey, key)) .limit(1); if (!fileRows[0]) { throw new NotFoundException(`File not found: ${key}`); } const mimeType = fileRows[0].mimeType ?? "application/octet-stream"; let bytes: Uint8Array; try { bytes = await this.storage.getBytes(key); } catch { throw new NotFoundException(`File not found in storage: ${key}`); } return res .status(HttpStatus.OK) .header("Content-Type", mimeType) .header("Cache-Control", "private, max-age=3600") .send(Buffer.from(bytes)); } // ------------------------------------------------------------------------- // Evidence list for a mission // ------------------------------------------------------------------------- @Get("missions/:id/evidence") async listEvidence(@Param("id") missionId: string, @CurrentUser() userId: string) { await this.assertMissionOwnership(missionId, userId); const items = await this.db .select() .from(evidenceItems) .where(eq(evidenceItems.missionId, missionId)) .orderBy(desc(evidenceItems.createdAt)); const result = await Promise.all( items.map(async (item) => { const parsedRows = await this.db .select() .from(evidenceParsedOutputs) .where(eq(evidenceParsedOutputs.evidenceItemId, item.id)) .orderBy(desc(evidenceParsedOutputs.createdAt)) .limit(1); const claimRows = await this.db .select() .from(evidenceClaims) .where(eq(evidenceClaims.evidenceItemId, item.id)) .orderBy(desc(evidenceClaims.createdAt)); let fileUrl: string | null = null; if (item.fileId) { const fileRow = await this.db .select({ storageKey: files.storageKey }) .from(files) .where(eq(files.id, item.fileId)) .limit(1); if (fileRow[0]) { fileUrl = this.storage.publicUrl(fileRow[0].storageKey); } } const parsed: ParsedEvidenceDto | null = parsedRows[0] ? { rawText: parsedRows[0].extractedText ?? "", entities: (parsedRows[0].extractedEntities as Record) ?? {}, dates: (parsedRows[0].extractedDates as string[]) ?? [], money: (parsedRows[0].extractedMoney as Array<{ amount: number; currency: string; original: string }>) ?? [], parties: (parsedRows[0].extractedParties as string[]) ?? [], confidence: parsedRows[0].ocrConfidence !== null ? Number(parsedRows[0].ocrConfidence) : 0, } : null; const claims: EvidenceClaimDto[] = claimRows.map((c) => ({ claimText: c.claimText, claimType: c.claimType ?? null, supportLevel: c.supportLevel as EvidenceClaimDto["supportLevel"], sourceSpan: (c.sourceSpan as Record) ?? null, })); return { id: item.id, type: item.type, sourceChannel: item.sourceChannel, captureDate: item.captureDate?.toISOString() ?? null, piiLevel: item.piiLevel, fileUrl, parsed, claims, }; }), ); return { items: result }; } // ------------------------------------------------------------------------- // Facts // ------------------------------------------------------------------------- @Get("missions/:id/facts") async getFacts(@Param("id") missionId: string, @CurrentUser() userId: string) { await this.assertMissionOwnership(missionId, userId); const rows = await this.db .select({ facts: missions.facts }) .from(missions) .where(eq(missions.id, missionId)) .limit(1); if (!rows[0]) throw new NotFoundException(`mission ${missionId} not found`); return { facts: rows[0].facts ?? null }; } @Post("missions/:id/facts/confirm") async confirmFacts( @Param("id") missionId: string, @CurrentUser() userId: string, @Body() body: unknown, ) { await this.assertMissionOwnership(missionId, userId); const input = parseOrThrow(ConfirmFactsInput, body); const result = await this.db.transaction(async (tx) => { const missionRows = await tx .select({ id: missions.id, userId: missions.userId }) .from(missions) .where(eq(missions.id, missionId)) .limit(1); if (!missionRows[0]) throw new NotFoundException(`mission ${missionId} not found`); await tx .update(missions) .set({ facts: input.facts, state: "facts_confirmed", updatedAt: new Date() }) .where(eq(missions.id, missionId)); await tx.insert(missionEvents).values({ missionId, eventType: "facts.confirmed", actorType: "user", actorId: missionRows[0].userId, payload: { facts: input.facts }, }); const [latestSnap] = await tx .select({ state: missionStatusSnapshots.state }) .from(missionStatusSnapshots) .where(eq(missionStatusSnapshots.missionId, missionId)) .orderBy(desc(missionStatusSnapshots.createdAt)) .limit(1); const currentRank = STATE_RANK[latestSnap?.state ?? ""] ?? -1; const newState = "facts_confirmed"; const newRank = STATE_RANK[newState] ?? 0; if (newRank >= currentRank) { await tx.insert(missionStatusSnapshots).values({ missionId, state: newState, substate: null, headline: "Facts confirmed — ready for legal grounding.", blockedReason: null, nextUserAction: null, nextSystemAction: "Run legal grounding.", primaryButton: "View status", danger: false, confidence: null, }); } return { ok: true, state: "facts_confirmed" as const }; }); await this.workflow.send("mission.facts_confirmed", { missionId }); await this.workflow.send("swarm.entity_resolution_requested", { missionId }); return result; } }