import { Inject, Injectable, NotFoundException } from "@nestjs/common"; import { eq, and, desc, isNull } from "drizzle-orm"; import { schema, type Database } from "@courtmitra/db"; import { computeConsentHash, canonicalizeConsentPreview, canExecute, checkDefamation, } from "@courtmitra/domain"; import { getCapabilities } from "@courtmitra/adapters"; import { STATE_RANK } from "@courtmitra/contracts"; import type { ConsentPreview, Action, RoutePlanStepDto, ActionPreviewDto, ActionPreviewResponse, ConsentGrantResponse, ExecuteActionResponse, ActionDetailDto, ActionAttemptDto, SubmissionDto, ActionExecutionRefused, CreateActionPreviewInput, } from "@courtmitra/contracts"; import { DB } from "../db/db.module.js"; import { WorkflowService } from "../workflow/inngest.module.js"; function proofStateAfterForAction(actionType: string): ActionPreviewResponse["proofStateAfter"] { switch (actionType) { case "send_email": case "takedown": return "sent_by_email"; case "generate_packet": case "prepare_portal": return "ready_for_user_submission"; case "post_social": return "posted_to_social"; default: return "draft_only"; } } function actionAlreadyExecuted(action: Pick): boolean { return action.status === "succeeded" || [ "sent_by_email", "sent_via_channel", "posted_to_social", "user_confirmed_external_submission", "receipt_verified", ].includes(action.proofState ?? ""); } @Injectable() export class ActionsService { constructor( @Inject(DB) private readonly db: Database, @Inject(WorkflowService) private readonly workflow: WorkflowService, ) {} async previewAction( missionId: string, input: CreateActionPreviewInput, idempotencyKey?: string, ): Promise { const [missionRow] = await this.db .select({ id: schema.missions.id, userId: schema.missions.userId }) .from(schema.missions) .where(eq(schema.missions.id, missionId)) .limit(1); if (!missionRow) throw new NotFoundException(`Mission not found: ${missionId}`); const [planRow] = await this.db .select() .from(schema.routePlans) .where( and( eq(schema.routePlans.id, input.routePlanId), eq(schema.routePlans.missionId, missionId), isNull(schema.routePlans.supersededAt), ), ) .limit(1); if (!planRow) throw new NotFoundException(`Route plan not found for mission: ${missionId}`); const steps = planRow.steps as unknown as RoutePlanStepDto[]; const step = steps.find((s) => s.stepNo === input.stepNo); if (!step) throw new NotFoundException(`Step ${input.stepNo} not found in route plan`); const defamationCheck = checkDefamation(input.body); const safeBody = defamationCheck.redactedBody; const preview: ConsentPreview = { actionType: step.actionType, destination: input.destination, public: false, body: safeBody, attachments: input.attachments, piiDisclosed: input.piiDisclosed, risks: input.risks, }; const hash = computeConsentHash(preview); const canonicalized = canonicalizeConsentPreview(preview); const inputPayload: Record = { ...canonicalized }; if (input.subject) { inputPayload["subject"] = input.subject; } const [actionRow] = await this.db .insert(schema.actions) .values({ missionId, routePlanId: input.routePlanId, actionType: step.actionType, title: step.title, description: step.description, inputPayload, canonicalPayloadHash: hash, consentRequired: true, status: "preview_ready", riskLevel: step.riskLevel, createdBy: "user", idempotencyKey: idempotencyKey ?? null, }) .returning(); // Only create snapshot if it does not regress state const [latestSnap] = await this.db .select({ state: schema.missionStatusSnapshots.state }) .from(schema.missionStatusSnapshots) .where(eq(schema.missionStatusSnapshots.missionId, missionId)) .orderBy(desc(schema.missionStatusSnapshots.createdAt)) .limit(1); const currentRank = STATE_RANK[latestSnap?.state ?? ""] ?? -1; const newRank = STATE_RANK["actions_preview_ready"] ?? 0; if (newRank >= currentRank) { await this.db.insert(schema.missionStatusSnapshots).values({ missionId, state: "actions_preview_ready", substate: null, headline: `Action preview ready: ${step.title}`, blockedReason: null, nextUserAction: "Review and approve the action to proceed.", nextSystemAction: "Wait for user consent before execution.", primaryButton: "Approve & send", danger: false, confidence: null, }); } const previewResponse: ActionPreviewResponse = { actionId: actionRow!.id, actionType: step.actionType, destination: input.destination, public: false, bodyPreview: safeBody.slice(0, 500), attachments: input.attachments, piiDisclosed: input.piiDisclosed, risks: input.risks, actionPreviewHash: hash, proofStateAfter: proofStateAfterForAction(step.actionType), }; const action: Action = { id: actionRow!.id, missionId: actionRow!.missionId, routePlanId: actionRow!.routePlanId, actionType: actionRow!.actionType as Action["actionType"], title: actionRow!.title, description: actionRow!.description, target: actionRow!.target as Record | null, inputPayload: actionRow!.inputPayload as Record | null, canonicalPayloadHash: actionRow!.canonicalPayloadHash, consentRequired: actionRow!.consentRequired, consentGrantId: actionRow!.consentGrantId, status: actionRow!.status as Action["status"], proofState: actionRow!.proofState as Action["proofState"], proofRef: actionRow!.proofRef as Record | null, idempotencyKey: actionRow!.idempotencyKey, riskLevel: actionRow!.riskLevel as Action["riskLevel"], createdBy: actionRow!.createdBy as Action["createdBy"], createdAt: actionRow!.createdAt.toISOString(), updatedAt: actionRow!.updatedAt.toISOString(), }; return { action, canonicalPayloadHash: hash, preview: previewResponse, }; } async grantConsent(actionId: string): Promise { const [actionRow] = await this.db .select() .from(schema.actions) .where(eq(schema.actions.id, actionId)) .limit(1); if (!actionRow) throw new NotFoundException(`Action not found: ${actionId}`); const [missionRow] = await this.db .select({ id: schema.missions.id, userId: schema.missions.userId }) .from(schema.missions) .where(eq(schema.missions.id, actionRow.missionId)) .limit(1); if (!missionRow) throw new NotFoundException(`Mission not found: ${actionRow.missionId}`); const [existingGrant] = await this.db .select() .from(schema.consentGrants) .where( and( eq(schema.consentGrants.missionId, actionRow.missionId), eq(schema.consentGrants.actionPreviewHash, actionRow.canonicalPayloadHash ?? ""), isNull(schema.consentGrants.revokedAt), ), ) .orderBy(desc(schema.consentGrants.grantedAt)) .limit(1); if (existingGrant) { // Link the existing consent to this action (it was created for a sibling action with the same hash). await this.db .update(schema.actions) .set({ consentGrantId: existingGrant.id, status: "approved", updatedAt: new Date() }) .where(eq(schema.actions.id, actionId)); return { id: existingGrant.id, actionId: actionRow.id, actionPreviewHash: existingGrant.actionPreviewHash, grantedAt: existingGrant.grantedAt?.toISOString() ?? new Date().toISOString(), }; } const now = new Date(); const [grant] = await this.db .insert(schema.consentGrants) .values({ missionId: actionRow.missionId, userId: missionRow.userId, actionType: actionRow.actionType, actionPreviewHash: actionRow.canonicalPayloadHash ?? "", consentText: "I approve this action", channel: "web", grantedAt: now, }) .returning(); await this.db .update(schema.actions) .set({ consentGrantId: grant!.id, status: "approved", updatedAt: new Date(), }) .where(eq(schema.actions.id, actionId)); return { id: grant!.id, actionId: actionRow.id, actionPreviewHash: grant!.actionPreviewHash, grantedAt: grant!.grantedAt?.toISOString() ?? now.toISOString(), }; } async executeAction( actionId: string, ): Promise { const [actionRow] = await this.db .select() .from(schema.actions) .where(eq(schema.actions.id, actionId)) .limit(1); if (!actionRow) throw new NotFoundException(`Action not found: ${actionId}`); if (actionAlreadyExecuted({ status: actionRow.status as Action["status"], proofState: actionRow.proofState as Action["proofState"], })) { return { refused: true, reason: "This action has already been executed.", code: "idempotency_key_used", }; } let consentGrant: { actionPreviewHash: string } | null = null; if (actionRow.consentGrantId) { const [grantRow] = await this.db .select({ actionPreviewHash: schema.consentGrants.actionPreviewHash }) .from(schema.consentGrants) .where(eq(schema.consentGrants.id, actionRow.consentGrantId)) .limit(1); if (grantRow) { consentGrant = grantRow; } } const capabilities = getCapabilities(); const action: Action = { id: actionRow.id, missionId: actionRow.missionId, routePlanId: actionRow.routePlanId, actionType: actionRow.actionType as Action["actionType"], title: actionRow.title, description: actionRow.description, target: null, inputPayload: actionRow.inputPayload as Record | null, canonicalPayloadHash: actionRow.canonicalPayloadHash, consentRequired: actionRow.consentRequired, consentGrantId: actionRow.consentGrantId, status: actionRow.status as Action["status"], proofState: actionRow.proofState as Action["proofState"], proofRef: null, idempotencyKey: actionRow.idempotencyKey, riskLevel: actionRow.riskLevel as Action["riskLevel"], createdBy: actionRow.createdBy as Action["createdBy"], createdAt: actionRow.createdAt.toISOString(), updatedAt: actionRow.updatedAt.toISOString(), }; const outgoingPreview: ConsentPreview = { actionType: actionRow.actionType, destination: (actionRow.inputPayload as Record)?.["destination"] as string ?? "", public: false, body: (actionRow.inputPayload as Record)?.["body"] as string ?? "", attachments: (actionRow.inputPayload as Record)?.["attachments"] as ConsentPreview["attachments"] ?? [], piiDisclosed: (actionRow.inputPayload as Record)?.["piiDisclosed"] as string[] ?? [], risks: (actionRow.inputPayload as Record)?.["risks"] as string[] ?? [], }; const gateResult = canExecute(action, consentGrant, outgoingPreview, capabilities); if (gateResult.refused) { return gateResult; } // Delegate execution to the Inngest worker. We DO NOT change status here — // the worker's executor sets it to "executing" inside its own transaction, // and canExecute allows "executing" so retries work. await this.workflow.send("action.execute_requested", { actionId: actionRow.id, missionId: actionRow.missionId, }); return { actionId: actionRow.id, proofState: "draft_only", status: "approved", attempt: null, }; } async getActionsForMission(missionId: string): Promise { const actionRows = await this.db .select() .from(schema.actions) .where(eq(schema.actions.missionId, missionId)) .orderBy(desc(schema.actions.createdAt)); return Promise.all( actionRows.map(async (actionRow) => { const [latestAttempt] = await this.db .select() .from(schema.actionAttempts) .where(eq(schema.actionAttempts.actionId, actionRow.id)) .orderBy(desc(schema.actionAttempts.attemptNo)) .limit(1); const submissionRows = await this.db .select() .from(schema.submissions) .where(eq(schema.submissions.actionId, actionRow.id)) .orderBy(desc(schema.submissions.createdAt)); const action: Action = { id: actionRow.id, missionId: actionRow.missionId, routePlanId: actionRow.routePlanId, actionType: actionRow.actionType as Action["actionType"], title: actionRow.title, description: actionRow.description, target: actionRow.target as Record | null, inputPayload: actionRow.inputPayload as Record | null, canonicalPayloadHash: actionRow.canonicalPayloadHash, consentRequired: actionRow.consentRequired, consentGrantId: actionRow.consentGrantId, status: actionRow.status as Action["status"], proofState: actionRow.proofState as Action["proofState"], proofRef: actionRow.proofRef as Record | null, idempotencyKey: actionRow.idempotencyKey, riskLevel: actionRow.riskLevel as Action["riskLevel"], createdBy: actionRow.createdBy as Action["createdBy"], createdAt: actionRow.createdAt.toISOString(), updatedAt: actionRow.updatedAt.toISOString(), }; const latestAttemptDto: ActionAttemptDto | null = latestAttempt ? { id: latestAttempt.id, adapterName: latestAttempt.adapterName, attemptNo: latestAttempt.attemptNo, status: latestAttempt.status, errorCode: latestAttempt.errorCode, errorMessage: latestAttempt.errorMessage, startedAt: latestAttempt.startedAt?.toISOString() ?? null, finishedAt: latestAttempt.finishedAt?.toISOString() ?? null, } : null; const submissionDtos: SubmissionDto[] = submissionRows.map((s) => ({ id: s.id, proofState: s.proofState as SubmissionDto["proofState"], externalReference: s.externalReference, sentAt: s.sentAt?.toISOString() ?? null, })); return { action, latestAttempt: latestAttemptDto, submissions: submissionDtos, }; }), ); } async getAction(actionId: string): Promise { const [actionRow] = await this.db .select() .from(schema.actions) .where(eq(schema.actions.id, actionId)) .limit(1); if (!actionRow) throw new NotFoundException(`Action not found: ${actionId}`); const [latestAttempt] = await this.db .select() .from(schema.actionAttempts) .where(eq(schema.actionAttempts.actionId, actionId)) .orderBy(desc(schema.actionAttempts.attemptNo)) .limit(1); const submissionRows = await this.db .select() .from(schema.submissions) .where(eq(schema.submissions.actionId, actionId)) .orderBy(desc(schema.submissions.createdAt)); const action: Action = { id: actionRow.id, missionId: actionRow.missionId, routePlanId: actionRow.routePlanId, actionType: actionRow.actionType as Action["actionType"], title: actionRow.title, description: actionRow.description, target: actionRow.target as Record | null, inputPayload: actionRow.inputPayload as Record | null, canonicalPayloadHash: actionRow.canonicalPayloadHash, consentRequired: actionRow.consentRequired, consentGrantId: actionRow.consentGrantId, status: actionRow.status as Action["status"], proofState: actionRow.proofState as Action["proofState"], proofRef: actionRow.proofRef as Record | null, idempotencyKey: actionRow.idempotencyKey, riskLevel: actionRow.riskLevel as Action["riskLevel"], createdBy: actionRow.createdBy as Action["createdBy"], createdAt: actionRow.createdAt.toISOString(), updatedAt: actionRow.updatedAt.toISOString(), }; const latestAttemptDto: ActionAttemptDto | null = latestAttempt ? { id: latestAttempt.id, adapterName: latestAttempt.adapterName, attemptNo: latestAttempt.attemptNo, status: latestAttempt.status, errorCode: latestAttempt.errorCode, errorMessage: latestAttempt.errorMessage, startedAt: latestAttempt.startedAt?.toISOString() ?? null, finishedAt: latestAttempt.finishedAt?.toISOString() ?? null, } : null; const submissions: SubmissionDto[] = submissionRows.map((s) => ({ id: s.id, proofState: s.proofState as SubmissionDto["proofState"], externalReference: s.externalReference, sentAt: s.sentAt?.toISOString() ?? null, })); return { action, latestAttempt: latestAttemptDto, submissions, }; } async confirmExternalSubmission(actionId: string, platform: string, externalUrl?: string) { const [actionRow] = await this.db .select() .from(schema.actions) .where(eq(schema.actions.id, actionId)) .limit(1); if (!actionRow) throw new NotFoundException(`Action not found: ${actionId}`); // Determine the correct headline based on action type const actionType = actionRow.actionType; const isSocialPost = platform === "x" || platform === "threads" || platform === "instagram"; let headline: string; let nextUserAction: string; let primaryButton: string | null; if (isSocialPost) { headline = `Social post published on ${platform} — waiting for public response.`; nextUserAction = "Monitor replies on your post and report any response here."; primaryButton = "View post"; } else if (actionType === "send_email") { headline = "Email sent to authority — waiting for response."; nextUserAction = "Check for replies from the authority and report back."; primaryButton = null; } else { headline = `Action "${actionRow.title}" confirmed — waiting for response.`; nextUserAction = "Wait for the authority to respond. You can also escalate on social media below."; primaryButton = null; } await this.db.transaction(async (tx) => { await tx .update(schema.actions) .set({ proofState: "user_confirmed_external_submission", status: "succeeded", updatedAt: new Date(), }) .where(eq(schema.actions.id, actionId)); await tx.insert(schema.submissions).values({ missionId: actionRow.missionId, actionId, adapterName: isSocialPost ? "social_publisher" : "web", status: "sent", proofState: "user_confirmed_external_submission", externalReference: externalUrl ?? null, payloadHash: actionRow.canonicalPayloadHash, sentAt: new Date(), }); await tx.insert(schema.missionEvents).values({ missionId: actionRow.missionId, eventType: "action_executed", actorType: "user", payload: { actionId, proofState: "user_confirmed_external_submission", platform, externalUrl, message: `User confirmed external submission on ${platform}`, }, }); const [latestSnapshot] = await tx .select({ state: schema.missionStatusSnapshots.state }) .from(schema.missionStatusSnapshots) .where(eq(schema.missionStatusSnapshots.missionId, actionRow.missionId)) .orderBy(desc(schema.missionStatusSnapshots.createdAt)) .limit(1); const newState = "waiting_for_response"; const newRank = STATE_RANK[newState] ?? 0; const currentRank = STATE_RANK[latestSnapshot?.state ?? ""] ?? -1; if (newRank >= currentRank) { await tx.insert(schema.missionStatusSnapshots).values({ missionId: actionRow.missionId, state: newState, headline, nextUserAction, nextSystemAction: "Follow up if no response within 7 days.", primaryButton, }); } }); return { actionId, proofState: "user_confirmed_external_submission" as const, platform, externalUrl: externalUrl ?? null, }; } }