courtmitra / apps /api /src /missions /missions.service.ts
Hetansh Waghela
fix: Critical auth + ownership + container bugs from audit
76b46a3
Raw
History Blame
8.77 kB
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<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 };
}
}