import { createHash } from "node:crypto"; import { Inject, Injectable, NotFoundException } from "@nestjs/common"; import { and, desc, eq, lt } from "drizzle-orm"; import { type Database, missions, missionEvents, missionStatusSnapshots, idempotencyKeys, conversations, messages, } from "@courtmitra/db"; import type { CreateMissionInput, UpdateMissionDomainInput, Mission, MissionEvent, MissionStatusSnapshot, } from "@courtmitra/contracts"; import { DB } from "../db/db.module.js"; import { WorkflowService } from "../workflow/inngest.module.js"; type MissionRow = typeof missions.$inferSelect; function sha256Json(value: unknown): string { return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex"); } /** Initial status snapshot for a freshly-created mission (arch §30.6 — UI reads this). */ function createdSnapshot(missionId: string) { return { missionId, state: "created" as const, substate: null, headline: "Mission created — let's gather the details.", blockedReason: null, nextUserAction: "Describe what happened and upload any evidence.", nextSystemAction: "Run intake to structure your grievance.", primaryButton: "Start intake", danger: false, confidence: null, }; } @Injectable() export class MissionsService { constructor( @Inject(DB) private readonly db: Database, @Inject(WorkflowService) private readonly workflow: WorkflowService, ) {} private toDto(m: MissionRow): Mission { return { id: m.id, userId: m.userId, title: m.title, issueDomain: m.issueDomain as Mission["issueDomain"], state: m.state as Mission["state"], safetyLevel: m.safetyLevel as Mission["safetyLevel"], jurisdiction: (m.jurisdiction as Mission["jurisdiction"]) ?? null, opposingParty: (m.opposingParty as Mission["opposingParty"]) ?? null, reliefRequested: (m.reliefRequested as Mission["reliefRequested"]) ?? null, facts: (m.facts as Mission["facts"]) ?? null, currentRoutePlanId: m.currentRoutePlanId ?? null, createdAt: m.createdAt.toISOString(), updatedAt: m.updatedAt.toISOString(), }; } /** * Create a mission. The mission row, its `mission.created` event and the * initial status snapshot are written in ONE transaction (PHASE_1 §3.5). * Honors Idempotency-Key (CLAUDE.md #7) — a repeat returns the same mission. * After creation, creates a synthetic conversation + message and triggers * the intake worker so the mission is processed automatically. */ async create(userId: string, input: CreateMissionInput, idempotencyKey?: string): Promise { // Check idempotency first, before any transaction if (idempotencyKey) { const prior = await this.db.select().from(idempotencyKeys).where(eq(idempotencyKeys.key, idempotencyKey)).limit(1); if (prior[0]?.responseHash) { const found = await this.db.select().from(missions).where(eq(missions.id, prior[0].responseHash)).limit(1); if (found[0]) return this.toDto(found[0]); } } const { mission } = await this.db.transaction(async (tx) => { const [m] = await tx .insert(missions) .values({ userId, title: input.title, issueDomain: input.issueDomain, state: "created", jurisdiction: input.jurisdiction ?? null, opposingParty: input.opposingParty ?? null, reliefRequested: input.reliefRequested ?? null, facts: input.description ? { description: input.description } : null, }) .returning(); await tx.insert(missionEvents).values({ missionId: m!.id, eventType: "mission.created", actorType: "user", actorId: userId, payload: { issueDomain: input.issueDomain, title: input.title, source: "browser" }, }); await tx.insert(missionStatusSnapshots).values(createdSnapshot(m!.id)); if (idempotencyKey) { await tx.insert(idempotencyKeys).values({ key: idempotencyKey, requestHash: sha256Json(input), responseHash: m!.id, status: "done", expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), }); } return { mission: m! }; }); // After the transaction, create a synthetic conversation + message and trigger intake. const messageBody = input.description ? `${input.title}\n\n${input.description}` : input.title; const [convRow] = await this.db .insert(conversations) .values({ userId, channel: "web", activeMissionId: mission.id, lastMessageAt: new Date(), }) .returning(); const [msgRow] = await this.db .insert(messages) .values({ conversationId: convRow!.id, missionId: mission.id, direction: "inbound", channel: "web", body: messageBody, }) .returning(); // Emit message.received to trigger the intake worker await this.workflow.send("message.received", { missionId: mission.id, conversationId: convRow!.id, messageId: msgRow!.id, }); return this.toDto(mission); } /** List missions for a specific user, newest first. */ async list(userId: string): Promise { const rows = await this.db .select() .from(missions) .where(eq(missions.userId, userId)) .orderBy(desc(missions.createdAt)) .limit(100); return rows.map((m) => this.toDto(m)); } /** Get a mission IF it belongs to the requesting user. */ async get(id: string, userId?: string): Promise { const where = userId ? and(eq(missions.id, id), eq(missions.userId, userId)) : eq(missions.id, id); const rows = await this.db.select().from(missions).where(where).limit(1); if (!rows[0]) throw new NotFoundException(`mission ${id} not found`); return this.toDto(rows[0]); } async latestSnapshot(missionId: string, userId?: string): Promise { await this.get(missionId, userId); const rows = await this.db .select() .from(missionStatusSnapshots) .where(eq(missionStatusSnapshots.missionId, missionId)) .orderBy(desc(missionStatusSnapshots.createdAt)) .limit(1); const s = rows[0]; if (!s) return null; return { id: s.id, missionId: s.missionId, state: s.state as MissionStatusSnapshot["state"], substate: s.substate ?? null, headline: s.headline, blockedReason: s.blockedReason ?? null, nextUserAction: s.nextUserAction ?? null, nextSystemAction: s.nextSystemAction ?? null, primaryButton: s.primaryButton ?? null, danger: s.danger, confidence: s.confidence === null ? null : Number(s.confidence), createdAt: s.createdAt.toISOString(), }; } async updateDomain(id: string, userId: string, input: UpdateMissionDomainInput): Promise { const mission = await this.get(id, userId); const [updated] = await this.db .update(missions) .set({ issueDomain: input.issueDomain, updatedAt: new Date() }) .where(eq(missions.id, id)) .returning(); if (!updated) throw new NotFoundException(`Mission not found: ${id}`); await this.db.insert(missionEvents).values({ missionId: id, eventType: "mission.domain_changed", actorType: "user", actorId: userId, payload: { from: mission.issueDomain, to: input.issueDomain }, }); return this.toDto(updated); } /** Cursor-paginated mission timeline (PHASE_1 §7), newest-first by createdAt. */ async timeline(missionId: string, userId: string, cursor?: string, limit = 50): Promise<{ items: MissionEvent[]; nextCursor: string | null }> { await this.get(missionId, userId); const where = cursor ? and(eq(missionEvents.missionId, missionId), lt(missionEvents.createdAt, new Date(cursor))) : eq(missionEvents.missionId, missionId); const rows = await this.db .select() .from(missionEvents) .where(where) .orderBy(desc(missionEvents.createdAt)) .limit(limit + 1); const hasMore = rows.length > limit; const page = hasMore ? rows.slice(0, limit) : rows; const items: MissionEvent[] = page.map((e) => ({ id: e.id, missionId: e.missionId, eventType: e.eventType, actorType: e.actorType as MissionEvent["actorType"], actorId: e.actorId ?? null, payload: (e.payload as MissionEvent["payload"]) ?? null, createdAt: e.createdAt.toISOString(), })); const last = page[page.length - 1]; return { items, nextCursor: hasMore && last ? last.createdAt.toISOString() : null }; } }