courtmitra / apps /api /src /actions /actions.service.ts
Vanshik Waghela
fix: state regression rank guards, social escalate persistence, missing info inputs, back buttons, demo data seed
408463e
Raw
History Blame
21.2 kB
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 ?? "");
}
@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<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,
};
}
}