/** * RoutePlansService — read/write logic for route_plans. * * CLAUDE.md invariants: * #1 – Hexagonal: no SDK imports; uses DB and WorkflowService ports. * #6 – Contracts-first: validates output via RoutePlanDto Zod schema. * #7 – Idempotent triggers: Inngest dedupes by event id; we pass the * Idempotency-Key as part of the event data for traceability. */ import { Inject, Injectable, NotFoundException } from "@nestjs/common"; import { and, desc, eq, isNull } from "drizzle-orm"; import type { Database } from "@courtmitra/db"; import { routePlans, missions, missionEvents } from "@courtmitra/db"; import { RoutePlanDto } from "@courtmitra/contracts"; import { WorkflowService } from "../workflow/inngest.module.js"; import { DB } from "../db/db.module.js"; @Injectable() export class RoutePlansService { constructor( @Inject(DB) private readonly db: Database, @Inject(WorkflowService) private readonly workflow: WorkflowService, ) {} /** * Emit a route_plan_requested event to the worker. Returns immediately (202). * Idempotency-Key is forwarded so the caller can dedupe if needed. */ async trigger(missionId: string, idempotencyKey?: string): Promise<{ missionId: string }> { // Verify mission exists. const [missionRow] = await this.db .select({ id: missions.id }) .from(missions) .where(eq(missions.id, missionId)) .limit(1); if (!missionRow) { throw new NotFoundException(`Mission not found: ${missionId}`); } // Append a request event (best-effort audit trail). await this.db.insert(missionEvents).values({ missionId, eventType: "route_plan_requested", actorType: "user", payload: { idempotencyKey: idempotencyKey ?? null }, }); await this.workflow.send("mission.route_plan_requested", { missionId, idempotencyKey: idempotencyKey ?? "", }); return { missionId }; } /** * Return the latest non-superseded route_plan for a mission, validated * against RoutePlanDto (CLAUDE.md #6 — never pass through unvalidated). */ async getLatest(missionId: string): Promise { // Verify mission exists. const [missionRow] = await this.db .select({ id: missions.id, issueDomain: missions.issueDomain, currentRoutePlanId: missions.currentRoutePlanId }) .from(missions) .where(eq(missions.id, missionId)) .limit(1); if (!missionRow) { throw new NotFoundException(`Mission not found: ${missionId}`); } // Prefer mission.currentRoutePlanId, then fall back to latest active plan. const [planByCurrentId] = missionRow.currentRoutePlanId ? await this.db .select() .from(routePlans) .where(eq(routePlans.id, missionRow.currentRoutePlanId)) .limit(1) : []; const [latestActivePlan] = await this.db .select() .from(routePlans) .where(and(eq(routePlans.missionId, missionId), isNull(routePlans.supersededAt))) .orderBy(desc(routePlans.createdAt)) .limit(1); const planRow = planByCurrentId?.supersededAt === null ? planByCurrentId : latestActivePlan; if (!planRow) { throw new NotFoundException(`No active route plan found for mission: ${missionId}`); } // Parse stored JSONB columns — these were persisted by the worker already enriched. const stepsData = (planRow.steps as unknown as Array>) ?? []; const claimsData = (planRow.citations as unknown as Array>) ?? []; // Build RoutePlanDto from the stored JSONB + mission row. const dto: unknown = { id: planRow.id, missionId, issueDomain: missionRow.issueDomain, summary: planRow.summary ?? "", steps: stepsData, claims: claimsData, // stored as grounded claims in citations column riskNotes: (planRow.riskNotes as unknown as string[]) ?? [], expectedTimelineDays: planRow.expectedTimeline && typeof planRow.expectedTimeline === "object" && "days" in (planRow.expectedTimeline as object) ? (planRow.expectedTimeline as Record)["days"] : null, confidence: planRow.confidence !== null ? Number(planRow.confidence) : 0, routeRuleId: null, // route_plans table doesn't store routeRuleId directly }; // Validate against RoutePlanDto — CLAUDE.md #6. return RoutePlanDto.parse(dto); } }