Spaces:
Sleeping
Sleeping
| 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, | |
| }; | |
| } | |
| () | |
| export class MissionsService { | |
| constructor( | |
| (DB) private readonly db: Database, | |
| (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<Mission> { | |
| // 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<Mission[]> { | |
| 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<Mission> { | |
| 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<MissionStatusSnapshot | null> { | |
| 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<Mission> { | |
| 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 }; | |
| } | |
| } | |