Spaces:
Starting
Starting
Vanshik Waghela
fix: state regression rank guards, social escalate persistence, missing info inputs, back buttons, demo data seed
408463e | 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<Action, "status" | "proofState">): boolean { | |
| return action.status === "succeeded" || [ | |
| "sent_by_email", | |
| "sent_via_channel", | |
| "posted_to_social", | |
| "user_confirmed_external_submission", | |
| "receipt_verified", | |
| ].includes(action.proofState ?? ""); | |
| } | |
| () | |
| export class ActionsService { | |
| constructor( | |
| (DB) private readonly db: Database, | |
| (WorkflowService) private readonly workflow: WorkflowService, | |
| ) {} | |
| async previewAction( | |
| missionId: string, | |
| input: CreateActionPreviewInput, | |
| idempotencyKey?: string, | |
| ): Promise<ActionPreviewDto> { | |
| 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<string, unknown> = { ...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<string, unknown> | null, | |
| inputPayload: actionRow!.inputPayload as Record<string, unknown> | 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<string, unknown> | 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<ConsentGrantResponse> { | |
| 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<ExecuteActionResponse | ActionExecutionRefused> { | |
| 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<string, unknown> | 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<string, unknown>)?.["destination"] as string ?? "", | |
| public: false, | |
| body: (actionRow.inputPayload as Record<string, unknown>)?.["body"] as string ?? "", | |
| attachments: (actionRow.inputPayload as Record<string, unknown>)?.["attachments"] as ConsentPreview["attachments"] ?? [], | |
| piiDisclosed: (actionRow.inputPayload as Record<string, unknown>)?.["piiDisclosed"] as string[] ?? [], | |
| risks: (actionRow.inputPayload as Record<string, unknown>)?.["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<ActionDetailDto[]> { | |
| 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<string, unknown> | null, | |
| inputPayload: actionRow.inputPayload as Record<string, unknown> | 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<string, unknown> | 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<ActionDetailDto> { | |
| 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<string, unknown> | null, | |
| inputPayload: actionRow.inputPayload as Record<string, unknown> | 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<string, unknown> | 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, | |
| }; | |
| } | |
| } | |