import { Body, Controller, Headers, HttpCode, HttpStatus, Inject, NotFoundException, Param, Post, UseGuards, ForbiddenException, } from "@nestjs/common"; import { eq, desc, and } from "drizzle-orm"; import { schema, type Database } from "@courtmitra/db"; import { canResolve } from "@courtmitra/domain"; import { ProposeResolutionInput, ConfirmResolutionInput, PublishAnonymizedInput, } from "@courtmitra/contracts"; import type { ResolutionOutcomeDto, } from "@courtmitra/contracts"; import { parseOrThrow } from "../common/zod-validation.pipe.js"; import { DB } from "../db/db.module.js"; import { WorkflowService } from "../workflow/inngest.module.js"; import { CurrentUser } from "../auth/current-user.decorator.js"; import { ClerkAuthGuard } from "../auth/clerk-auth.guard.js"; @Controller() export class ResolutionController { constructor( @Inject(DB) private readonly db: Database, @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: schema.missions.userId }) .from(schema.missions) .where(eq(schema.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"); } @Post("missions/:missionId/resolution/propose") @HttpCode(HttpStatus.OK) async propose( @Param("missionId") missionId: string, @CurrentUser() userId: string, @Body() body: unknown, ) { await this.assertMissionOwnership(missionId, userId); const input = parseOrThrow(ProposeResolutionInput, body); const [mission] = await this.db .select({ id: schema.missions.id, state: schema.missions.state, issueDomain: schema.missions.issueDomain }) .from(schema.missions) .where(eq(schema.missions.id, missionId)) .limit(1); if (!mission) throw new NotFoundException(`Mission not found: ${missionId}`); const check = canResolve(mission); if (!check.allowed) { return { allowed: false, reason: check.reason }; } return { allowed: true, preview: { outcomeType: input.outcomeType, amountRecovered: input.amountRecovered ?? null, userSatisfaction: input.userSatisfaction ?? null, publishConsent: input.publishConsent, anonymizedPublicSummary: input.anonymizedPublicSummary ?? "(no summary provided)", domain: mission.issueDomain, }, }; } @Post("missions/:missionId/resolution/confirm") @HttpCode(HttpStatus.CREATED) async confirm( @Param("missionId") missionId: string, @CurrentUser() userId: string, @Body() body: unknown, @Headers("idempotency-key") idempotencyKey?: string, ) { await this.assertMissionOwnership(missionId, userId); const input = parseOrThrow(ConfirmResolutionInput, body); const [mission] = await this.db .select({ id: schema.missions.id, state: schema.missions.state, userId: schema.missions.userId }) .from(schema.missions) .where(eq(schema.missions.id, missionId)) .limit(1); if (!mission) throw new NotFoundException(`Mission not found: ${missionId}`); const check = canResolve(mission); if (!check.allowed) { return { refused: true, reason: check.reason }; } const [outcome] = await this.db .insert(schema.resolutionOutcomes) .values({ missionId, outcomeType: input.outcomeType, amountRecovered: input.amountRecovered ? String(input.amountRecovered) : null, userSatisfaction: input.userSatisfaction ?? null, anonymizedPublicSummary: input.anonymizedPublicSummary ?? null, publishConsent: input.publishConsent, }) .returning(); if (!outcome) throw new Error("Failed to create resolution outcome"); await this.db .update(schema.missions) .set({ state: "resolution_claimed", updatedAt: new Date() }) .where(eq(schema.missions.id, missionId)); await this.db.insert(schema.missionEvents).values({ missionId, eventType: "resolution_claimed", actorType: "user", actorId: mission.userId, payload: { outcomeType: input.outcomeType, outcomeId: outcome.id, idempotencyKey }, }); await this.db.insert(schema.missionStatusSnapshots).values({ missionId, state: "resolution_claimed", headline: `Outcome recorded: ${input.outcomeType.replace(/_/g, " ")}`, nextUserAction: null, nextSystemAction: "Verifying closure and publishing ledger entry if consented.", primaryButton: "View result", danger: false, confidence: null, }); await this.workflow.send("mission.resolution_claimed", { missionId, outcomeId: outcome.id, publishConsent: String(input.publishConsent), }); return { id: outcome.id, outcomeType: outcome.outcomeType, missionId: outcome.missionId, amountRecovered: outcome.amountRecovered ? Number(outcome.amountRecovered) : null, userSatisfaction: outcome.userSatisfaction, anonymizedPublicSummary: outcome.anonymizedPublicSummary, publishConsent: outcome.publishConsent, createdAt: outcome.createdAt.toISOString(), } satisfies ResolutionOutcomeDto; } @Post("missions/:missionId/resolution/publish-anonymized") @HttpCode(HttpStatus.OK) async publishAnonymized( @Param("missionId") missionId: string, @CurrentUser() userId: string, @Body() body: unknown, ) { await this.assertMissionOwnership(missionId, userId); const input = parseOrThrow(PublishAnonymizedInput, body); const [latestOutcome] = await this.db .select() .from(schema.resolutionOutcomes) .where(eq(schema.resolutionOutcomes.missionId, missionId)) .orderBy(desc(schema.resolutionOutcomes.createdAt)) .limit(1); if (!latestOutcome) throw new NotFoundException(`No resolution outcome found for mission: ${missionId}`); await this.db .update(schema.resolutionOutcomes) .set({ publishConsent: true, anonymizedPublicSummary: input.anonymizedPublicSummary, }) .where(eq(schema.resolutionOutcomes.id, latestOutcome.id)); await this.workflow.send("ledger.outcome_recorded", { outcomeId: latestOutcome.id, missionId, }); return { ok: true, outcomeId: latestOutcome.id }; } }