Spaces:
Sleeping
Sleeping
Hetansh Waghela commited on
Commit ·
1b87b65
1
Parent(s): 2e15351
completing day 3
Browse files- apps/api/src/actions/actions.controller.ts +127 -0
- apps/api/src/actions/actions.module.ts +9 -0
- apps/api/src/actions/actions.service.ts +377 -0
- apps/api/src/app.module.ts +2 -0
- apps/api/src/workflow/inngest.module.ts +5 -1
- apps/web/src/app/missions/[id]/page.tsx +229 -1
- apps/web/src/app/p/[slug]/page.tsx +154 -0
- apps/web/src/lib/api.ts +29 -0
- apps/worker/src/functions/action-execute.ts +45 -0
- apps/worker/src/functions/followup-schedule.ts +66 -0
- apps/worker/src/inngest.ts +4 -0
- apps/worker/src/lib/action-executor.ts +218 -0
- packages/adapters/package.json +1 -0
- packages/adapters/src/__tests__/regex-redaction.test.ts +155 -0
- packages/adapters/src/index.ts +12 -2
- packages/adapters/src/redaction/regex.ts +155 -0
- packages/adapters/src/submission/resend-email.ts +185 -0
- packages/contracts/src/action.ts +140 -0
- packages/domain/src/index.ts +3 -0
- packages/domain/src/policies/__tests__/action-execution-policy.test.ts +147 -0
- packages/domain/src/policies/action-execution-policy.ts +126 -0
- packages/ports/src/index.ts +21 -0
- pnpm-lock.yaml +40 -0
apps/api/src/actions/actions.controller.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Body,
|
| 3 |
+
Controller,
|
| 4 |
+
Get,
|
| 5 |
+
Headers,
|
| 6 |
+
HttpCode,
|
| 7 |
+
HttpStatus,
|
| 8 |
+
Inject,
|
| 9 |
+
NotFoundException,
|
| 10 |
+
Param,
|
| 11 |
+
Post,
|
| 12 |
+
} from "@nestjs/common";
|
| 13 |
+
import { eq, desc } from "drizzle-orm";
|
| 14 |
+
import { schema, type Database } from "@courtmitra/db";
|
| 15 |
+
import {
|
| 16 |
+
CreateActionPreviewInput,
|
| 17 |
+
GrantConsentInput,
|
| 18 |
+
PublicCasePageDto,
|
| 19 |
+
} from "@courtmitra/contracts";
|
| 20 |
+
import { parseOrThrow } from "../common/zod-validation.pipe.js";
|
| 21 |
+
import { DB } from "../db/db.module.js";
|
| 22 |
+
import { ActionsService } from "./actions.service.js";
|
| 23 |
+
|
| 24 |
+
@Controller()
|
| 25 |
+
export class ActionsController {
|
| 26 |
+
constructor(
|
| 27 |
+
@Inject(ActionsService) private readonly actions: ActionsService,
|
| 28 |
+
@Inject(DB) private readonly db: Database,
|
| 29 |
+
) {}
|
| 30 |
+
|
| 31 |
+
@Post("api/missions/:missionId/actions/preview")
|
| 32 |
+
@HttpCode(HttpStatus.CREATED)
|
| 33 |
+
async preview(
|
| 34 |
+
@Param("missionId") missionId: string,
|
| 35 |
+
@Body() body: unknown,
|
| 36 |
+
@Headers("idempotency-key") idempotencyKey?: string,
|
| 37 |
+
) {
|
| 38 |
+
const input = parseOrThrow(CreateActionPreviewInput, body);
|
| 39 |
+
return this.actions.previewAction(missionId, input, idempotencyKey);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
@Post("api/actions/:actionId/consent")
|
| 43 |
+
async grantConsent(@Param("actionId") actionId: string) {
|
| 44 |
+
return this.actions.grantConsent(actionId);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
@Post("api/actions/:actionId/execute")
|
| 48 |
+
@HttpCode(HttpStatus.ACCEPTED)
|
| 49 |
+
async execute(@Param("actionId") actionId: string) {
|
| 50 |
+
return this.actions.executeAction(actionId);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
@Get("api/actions/:actionId")
|
| 54 |
+
async get(@Param("actionId") actionId: string) {
|
| 55 |
+
return this.actions.getAction(actionId);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
@Get("api/public-pages/:slug")
|
| 59 |
+
async getPublicPage(@Param("slug") slug: string) {
|
| 60 |
+
const [pageRow] = await this.db
|
| 61 |
+
.select({
|
| 62 |
+
id: schema.publicCasePages.id,
|
| 63 |
+
missionId: schema.publicCasePages.missionId,
|
| 64 |
+
slug: schema.publicCasePages.slug,
|
| 65 |
+
visibility: schema.publicCasePages.visibility,
|
| 66 |
+
redactedSummary: schema.publicCasePages.redactedSummary,
|
| 67 |
+
createdAt: schema.publicCasePages.createdAt,
|
| 68 |
+
})
|
| 69 |
+
.from(schema.publicCasePages)
|
| 70 |
+
.where(eq(schema.publicCasePages.slug, slug))
|
| 71 |
+
.limit(1);
|
| 72 |
+
|
| 73 |
+
if (!pageRow) throw new NotFoundException(`Public page not found: ${slug}`);
|
| 74 |
+
|
| 75 |
+
const [missionRow] = await this.db
|
| 76 |
+
.select({
|
| 77 |
+
title: schema.missions.title,
|
| 78 |
+
issueDomain: schema.missions.issueDomain,
|
| 79 |
+
state: schema.missions.state,
|
| 80 |
+
})
|
| 81 |
+
.from(schema.missions)
|
| 82 |
+
.where(eq(schema.missions.id, pageRow.missionId))
|
| 83 |
+
.limit(1);
|
| 84 |
+
|
| 85 |
+
const latestActionRows = await this.db
|
| 86 |
+
.select({
|
| 87 |
+
id: schema.actions.id,
|
| 88 |
+
actionType: schema.actions.actionType,
|
| 89 |
+
title: schema.actions.title,
|
| 90 |
+
status: schema.actions.status,
|
| 91 |
+
proofState: schema.actions.proofState,
|
| 92 |
+
createdAt: schema.actions.createdAt,
|
| 93 |
+
})
|
| 94 |
+
.from(schema.actions)
|
| 95 |
+
.where(eq(schema.actions.missionId, pageRow.missionId))
|
| 96 |
+
.orderBy(desc(schema.actions.createdAt))
|
| 97 |
+
.limit(10);
|
| 98 |
+
|
| 99 |
+
const dto: PublicCasePageDto = {
|
| 100 |
+
id: pageRow.id,
|
| 101 |
+
missionId: pageRow.missionId,
|
| 102 |
+
slug: pageRow.slug,
|
| 103 |
+
visibility: pageRow.visibility,
|
| 104 |
+
redactedSummary: pageRow.redactedSummary,
|
| 105 |
+
createdAt: pageRow.createdAt.toISOString(),
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
return {
|
| 109 |
+
page: dto,
|
| 110 |
+
mission: missionRow
|
| 111 |
+
? {
|
| 112 |
+
title: `User reports / unresolved / alleged issue`,
|
| 113 |
+
issueDomain: missionRow.issueDomain,
|
| 114 |
+
state: missionRow.state,
|
| 115 |
+
}
|
| 116 |
+
: null,
|
| 117 |
+
actions: latestActionRows.map((a) => ({
|
| 118 |
+
id: a.id,
|
| 119 |
+
actionType: a.actionType,
|
| 120 |
+
title: a.title,
|
| 121 |
+
status: a.status,
|
| 122 |
+
proofState: a.proofState,
|
| 123 |
+
createdAt: a.createdAt.toISOString(),
|
| 124 |
+
})),
|
| 125 |
+
};
|
| 126 |
+
}
|
| 127 |
+
}
|
apps/api/src/actions/actions.module.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Module } from "@nestjs/common";
|
| 2 |
+
import { ActionsController } from "./actions.controller.js";
|
| 3 |
+
import { ActionsService } from "./actions.service.js";
|
| 4 |
+
|
| 5 |
+
@Module({
|
| 6 |
+
controllers: [ActionsController],
|
| 7 |
+
providers: [ActionsService],
|
| 8 |
+
})
|
| 9 |
+
export class ActionsModule {}
|
apps/api/src/actions/actions.service.ts
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
| 2 |
+
import { eq, and, desc, isNull } from "drizzle-orm";
|
| 3 |
+
import { schema, type Database } from "@courtmitra/db";
|
| 4 |
+
import {
|
| 5 |
+
computeConsentHash,
|
| 6 |
+
canonicalizeConsentPreview,
|
| 7 |
+
canExecute,
|
| 8 |
+
} from "@courtmitra/domain";
|
| 9 |
+
import { getCapabilities } from "@courtmitra/adapters";
|
| 10 |
+
import type {
|
| 11 |
+
ConsentPreview,
|
| 12 |
+
Action,
|
| 13 |
+
RoutePlanStepDto,
|
| 14 |
+
ActionPreviewDto,
|
| 15 |
+
ActionPreviewResponse,
|
| 16 |
+
ConsentGrantResponse,
|
| 17 |
+
ExecuteActionResponse,
|
| 18 |
+
ActionDetailDto,
|
| 19 |
+
ActionAttemptDto,
|
| 20 |
+
SubmissionDto,
|
| 21 |
+
ActionExecutionRefused,
|
| 22 |
+
CreateActionPreviewInput,
|
| 23 |
+
} from "@courtmitra/contracts";
|
| 24 |
+
import { DB } from "../db/db.module.js";
|
| 25 |
+
import { WorkflowService } from "../workflow/inngest.module.js";
|
| 26 |
+
|
| 27 |
+
@Injectable()
|
| 28 |
+
export class ActionsService {
|
| 29 |
+
constructor(
|
| 30 |
+
@Inject(DB) private readonly db: Database,
|
| 31 |
+
@Inject(WorkflowService) private readonly workflow: WorkflowService,
|
| 32 |
+
) {}
|
| 33 |
+
|
| 34 |
+
async previewAction(
|
| 35 |
+
missionId: string,
|
| 36 |
+
input: CreateActionPreviewInput,
|
| 37 |
+
idempotencyKey?: string,
|
| 38 |
+
): Promise<ActionPreviewDto> {
|
| 39 |
+
const [missionRow] = await this.db
|
| 40 |
+
.select({ id: schema.missions.id, userId: schema.missions.userId })
|
| 41 |
+
.from(schema.missions)
|
| 42 |
+
.where(eq(schema.missions.id, missionId))
|
| 43 |
+
.limit(1);
|
| 44 |
+
if (!missionRow) throw new NotFoundException(`Mission not found: ${missionId}`);
|
| 45 |
+
|
| 46 |
+
const [planRow] = await this.db
|
| 47 |
+
.select()
|
| 48 |
+
.from(schema.routePlans)
|
| 49 |
+
.where(
|
| 50 |
+
and(
|
| 51 |
+
eq(schema.routePlans.id, input.routePlanId),
|
| 52 |
+
eq(schema.routePlans.missionId, missionId),
|
| 53 |
+
isNull(schema.routePlans.supersededAt),
|
| 54 |
+
),
|
| 55 |
+
)
|
| 56 |
+
.limit(1);
|
| 57 |
+
if (!planRow) throw new NotFoundException(`Route plan not found for mission: ${missionId}`);
|
| 58 |
+
|
| 59 |
+
const steps = planRow.steps as unknown as RoutePlanStepDto[];
|
| 60 |
+
const step = steps.find((s) => s.stepNo === input.stepNo);
|
| 61 |
+
if (!step) throw new NotFoundException(`Step ${input.stepNo} not found in route plan`);
|
| 62 |
+
|
| 63 |
+
const preview: ConsentPreview = {
|
| 64 |
+
actionType: step.actionType,
|
| 65 |
+
destination: input.destination,
|
| 66 |
+
public: false,
|
| 67 |
+
body: input.body,
|
| 68 |
+
attachments: input.attachments,
|
| 69 |
+
piiDisclosed: input.piiDisclosed,
|
| 70 |
+
risks: input.risks,
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
const hash = computeConsentHash(preview);
|
| 74 |
+
|
| 75 |
+
const canonicalized = canonicalizeConsentPreview(preview);
|
| 76 |
+
|
| 77 |
+
const [actionRow] = await this.db
|
| 78 |
+
.insert(schema.actions)
|
| 79 |
+
.values({
|
| 80 |
+
missionId,
|
| 81 |
+
routePlanId: input.routePlanId,
|
| 82 |
+
actionType: step.actionType,
|
| 83 |
+
title: step.title,
|
| 84 |
+
description: step.description,
|
| 85 |
+
inputPayload: canonicalized as Record<string, unknown>,
|
| 86 |
+
canonicalPayloadHash: hash,
|
| 87 |
+
consentRequired: true,
|
| 88 |
+
status: "preview_ready",
|
| 89 |
+
riskLevel: step.riskLevel,
|
| 90 |
+
createdBy: "user",
|
| 91 |
+
idempotencyKey: idempotencyKey ?? null,
|
| 92 |
+
})
|
| 93 |
+
.returning();
|
| 94 |
+
|
| 95 |
+
await this.db.insert(schema.missionStatusSnapshots).values({
|
| 96 |
+
missionId,
|
| 97 |
+
state: "actions_preview_ready",
|
| 98 |
+
substate: null,
|
| 99 |
+
headline: `Action preview ready: ${step.title}`,
|
| 100 |
+
blockedReason: null,
|
| 101 |
+
nextUserAction: "Review and approve the action to proceed.",
|
| 102 |
+
nextSystemAction: "Wait for user consent before execution.",
|
| 103 |
+
primaryButton: "Approve & send",
|
| 104 |
+
danger: false,
|
| 105 |
+
confidence: null,
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
const previewResponse: ActionPreviewResponse = {
|
| 109 |
+
actionId: actionRow!.id,
|
| 110 |
+
actionType: step.actionType,
|
| 111 |
+
destination: input.destination,
|
| 112 |
+
public: false,
|
| 113 |
+
bodyPreview: input.body.slice(0, 500),
|
| 114 |
+
attachments: input.attachments,
|
| 115 |
+
piiDisclosed: input.piiDisclosed,
|
| 116 |
+
risks: input.risks,
|
| 117 |
+
actionPreviewHash: hash,
|
| 118 |
+
proofStateAfter: "draft_only",
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
const action: Action = {
|
| 122 |
+
id: actionRow!.id,
|
| 123 |
+
missionId: actionRow!.missionId,
|
| 124 |
+
routePlanId: actionRow!.routePlanId,
|
| 125 |
+
actionType: actionRow!.actionType as Action["actionType"],
|
| 126 |
+
title: actionRow!.title,
|
| 127 |
+
description: actionRow!.description,
|
| 128 |
+
target: actionRow!.target as Record<string, unknown> | null,
|
| 129 |
+
inputPayload: actionRow!.inputPayload as Record<string, unknown> | null,
|
| 130 |
+
canonicalPayloadHash: actionRow!.canonicalPayloadHash,
|
| 131 |
+
consentRequired: actionRow!.consentRequired,
|
| 132 |
+
consentGrantId: actionRow!.consentGrantId,
|
| 133 |
+
status: actionRow!.status as Action["status"],
|
| 134 |
+
proofState: actionRow!.proofState as Action["proofState"],
|
| 135 |
+
proofRef: actionRow!.proofRef as Record<string, unknown> | null,
|
| 136 |
+
idempotencyKey: actionRow!.idempotencyKey,
|
| 137 |
+
riskLevel: actionRow!.riskLevel as Action["riskLevel"],
|
| 138 |
+
createdBy: actionRow!.createdBy as Action["createdBy"],
|
| 139 |
+
createdAt: actionRow!.createdAt.toISOString(),
|
| 140 |
+
updatedAt: actionRow!.updatedAt.toISOString(),
|
| 141 |
+
};
|
| 142 |
+
|
| 143 |
+
return {
|
| 144 |
+
action,
|
| 145 |
+
canonicalPayloadHash: hash,
|
| 146 |
+
preview: previewResponse,
|
| 147 |
+
};
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
async grantConsent(actionId: string): Promise<ConsentGrantResponse> {
|
| 151 |
+
const [actionRow] = await this.db
|
| 152 |
+
.select()
|
| 153 |
+
.from(schema.actions)
|
| 154 |
+
.where(eq(schema.actions.id, actionId))
|
| 155 |
+
.limit(1);
|
| 156 |
+
if (!actionRow) throw new NotFoundException(`Action not found: ${actionId}`);
|
| 157 |
+
|
| 158 |
+
const [missionRow] = await this.db
|
| 159 |
+
.select({ id: schema.missions.id, userId: schema.missions.userId })
|
| 160 |
+
.from(schema.missions)
|
| 161 |
+
.where(eq(schema.missions.id, actionRow.missionId))
|
| 162 |
+
.limit(1);
|
| 163 |
+
if (!missionRow) throw new NotFoundException(`Mission not found: ${actionRow.missionId}`);
|
| 164 |
+
|
| 165 |
+
const [existingGrant] = await this.db
|
| 166 |
+
.select()
|
| 167 |
+
.from(schema.consentGrants)
|
| 168 |
+
.where(
|
| 169 |
+
and(
|
| 170 |
+
eq(schema.consentGrants.missionId, actionRow.missionId),
|
| 171 |
+
eq(schema.consentGrants.actionPreviewHash, actionRow.canonicalPayloadHash ?? ""),
|
| 172 |
+
isNull(schema.consentGrants.revokedAt),
|
| 173 |
+
),
|
| 174 |
+
)
|
| 175 |
+
.orderBy(desc(schema.consentGrants.grantedAt))
|
| 176 |
+
.limit(1);
|
| 177 |
+
|
| 178 |
+
if (existingGrant) {
|
| 179 |
+
// Link the existing consent to this action (it was created for a sibling action with the same hash).
|
| 180 |
+
await this.db
|
| 181 |
+
.update(schema.actions)
|
| 182 |
+
.set({ consentGrantId: existingGrant.id, status: "approved", updatedAt: new Date() })
|
| 183 |
+
.where(eq(schema.actions.id, actionId));
|
| 184 |
+
|
| 185 |
+
return {
|
| 186 |
+
id: existingGrant.id,
|
| 187 |
+
actionId: actionRow.id,
|
| 188 |
+
actionPreviewHash: existingGrant.actionPreviewHash,
|
| 189 |
+
grantedAt: existingGrant.grantedAt?.toISOString() ?? new Date().toISOString(),
|
| 190 |
+
};
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
const now = new Date();
|
| 194 |
+
const [grant] = await this.db
|
| 195 |
+
.insert(schema.consentGrants)
|
| 196 |
+
.values({
|
| 197 |
+
missionId: actionRow.missionId,
|
| 198 |
+
userId: missionRow.userId,
|
| 199 |
+
actionType: actionRow.actionType,
|
| 200 |
+
actionPreviewHash: actionRow.canonicalPayloadHash ?? "",
|
| 201 |
+
consentText: "I approve this action",
|
| 202 |
+
channel: "web",
|
| 203 |
+
grantedAt: now,
|
| 204 |
+
})
|
| 205 |
+
.returning();
|
| 206 |
+
|
| 207 |
+
await this.db
|
| 208 |
+
.update(schema.actions)
|
| 209 |
+
.set({
|
| 210 |
+
consentGrantId: grant!.id,
|
| 211 |
+
status: "approved",
|
| 212 |
+
updatedAt: new Date(),
|
| 213 |
+
})
|
| 214 |
+
.where(eq(schema.actions.id, actionId));
|
| 215 |
+
|
| 216 |
+
return {
|
| 217 |
+
id: grant!.id,
|
| 218 |
+
actionId: actionRow.id,
|
| 219 |
+
actionPreviewHash: grant!.actionPreviewHash,
|
| 220 |
+
grantedAt: grant!.grantedAt?.toISOString() ?? now.toISOString(),
|
| 221 |
+
};
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
async executeAction(
|
| 225 |
+
actionId: string,
|
| 226 |
+
): Promise<ExecuteActionResponse | ActionExecutionRefused> {
|
| 227 |
+
const [actionRow] = await this.db
|
| 228 |
+
.select()
|
| 229 |
+
.from(schema.actions)
|
| 230 |
+
.where(eq(schema.actions.id, actionId))
|
| 231 |
+
.limit(1);
|
| 232 |
+
if (!actionRow) throw new NotFoundException(`Action not found: ${actionId}`);
|
| 233 |
+
|
| 234 |
+
if (actionRow.status === "succeeded" || actionRow.proofState === "sent_by_email") {
|
| 235 |
+
return {
|
| 236 |
+
refused: true,
|
| 237 |
+
reason: "This action has already been executed.",
|
| 238 |
+
code: "idempotency_key_used",
|
| 239 |
+
};
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
let consentGrant: { actionPreviewHash: string } | null = null;
|
| 243 |
+
if (actionRow.consentGrantId) {
|
| 244 |
+
const [grantRow] = await this.db
|
| 245 |
+
.select({ actionPreviewHash: schema.consentGrants.actionPreviewHash })
|
| 246 |
+
.from(schema.consentGrants)
|
| 247 |
+
.where(eq(schema.consentGrants.id, actionRow.consentGrantId))
|
| 248 |
+
.limit(1);
|
| 249 |
+
if (grantRow) {
|
| 250 |
+
consentGrant = grantRow;
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
const capabilities = getCapabilities();
|
| 255 |
+
const action: Action = {
|
| 256 |
+
id: actionRow.id,
|
| 257 |
+
missionId: actionRow.missionId,
|
| 258 |
+
routePlanId: actionRow.routePlanId,
|
| 259 |
+
actionType: actionRow.actionType as Action["actionType"],
|
| 260 |
+
title: actionRow.title,
|
| 261 |
+
description: actionRow.description,
|
| 262 |
+
target: null,
|
| 263 |
+
inputPayload: actionRow.inputPayload as Record<string, unknown> | null,
|
| 264 |
+
canonicalPayloadHash: actionRow.canonicalPayloadHash,
|
| 265 |
+
consentRequired: actionRow.consentRequired,
|
| 266 |
+
consentGrantId: actionRow.consentGrantId,
|
| 267 |
+
status: actionRow.status as Action["status"],
|
| 268 |
+
proofState: actionRow.proofState as Action["proofState"],
|
| 269 |
+
proofRef: null,
|
| 270 |
+
idempotencyKey: actionRow.idempotencyKey,
|
| 271 |
+
riskLevel: actionRow.riskLevel as Action["riskLevel"],
|
| 272 |
+
createdBy: actionRow.createdBy as Action["createdBy"],
|
| 273 |
+
createdAt: actionRow.createdAt.toISOString(),
|
| 274 |
+
updatedAt: actionRow.updatedAt.toISOString(),
|
| 275 |
+
};
|
| 276 |
+
|
| 277 |
+
const outgoingPreview: ConsentPreview = {
|
| 278 |
+
actionType: actionRow.actionType,
|
| 279 |
+
destination: (actionRow.inputPayload as Record<string, unknown>)?.["destination"] as string ?? "",
|
| 280 |
+
public: false,
|
| 281 |
+
body: (actionRow.inputPayload as Record<string, unknown>)?.["body"] as string ?? "",
|
| 282 |
+
attachments: (actionRow.inputPayload as Record<string, unknown>)?.["attachments"] as ConsentPreview["attachments"] ?? [],
|
| 283 |
+
piiDisclosed: (actionRow.inputPayload as Record<string, unknown>)?.["piiDisclosed"] as string[] ?? [],
|
| 284 |
+
risks: (actionRow.inputPayload as Record<string, unknown>)?.["risks"] as string[] ?? [],
|
| 285 |
+
};
|
| 286 |
+
|
| 287 |
+
const gateResult = canExecute(action, consentGrant, outgoingPreview, capabilities);
|
| 288 |
+
if (gateResult.refused) {
|
| 289 |
+
return gateResult;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
// Delegate execution to the Inngest worker. We DO NOT change status here —
|
| 293 |
+
// the worker's executor sets it to "executing" inside its own transaction,
|
| 294 |
+
// and canExecute allows "executing" so retries work.
|
| 295 |
+
await this.workflow.send("action.execute_requested" as any, {
|
| 296 |
+
actionId: actionRow.id,
|
| 297 |
+
missionId: actionRow.missionId,
|
| 298 |
+
} as any);
|
| 299 |
+
|
| 300 |
+
return {
|
| 301 |
+
actionId: actionRow.id,
|
| 302 |
+
proofState: "draft_only",
|
| 303 |
+
status: "approved",
|
| 304 |
+
attempt: null,
|
| 305 |
+
};
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
async getAction(actionId: string): Promise<ActionDetailDto> {
|
| 309 |
+
const [actionRow] = await this.db
|
| 310 |
+
.select()
|
| 311 |
+
.from(schema.actions)
|
| 312 |
+
.where(eq(schema.actions.id, actionId))
|
| 313 |
+
.limit(1);
|
| 314 |
+
if (!actionRow) throw new NotFoundException(`Action not found: ${actionId}`);
|
| 315 |
+
|
| 316 |
+
const [latestAttempt] = await this.db
|
| 317 |
+
.select()
|
| 318 |
+
.from(schema.actionAttempts)
|
| 319 |
+
.where(eq(schema.actionAttempts.actionId, actionId))
|
| 320 |
+
.orderBy(desc(schema.actionAttempts.attemptNo))
|
| 321 |
+
.limit(1);
|
| 322 |
+
|
| 323 |
+
const submissionRows = await this.db
|
| 324 |
+
.select()
|
| 325 |
+
.from(schema.submissions)
|
| 326 |
+
.where(eq(schema.submissions.actionId, actionId))
|
| 327 |
+
.orderBy(desc(schema.submissions.createdAt));
|
| 328 |
+
|
| 329 |
+
const action: Action = {
|
| 330 |
+
id: actionRow.id,
|
| 331 |
+
missionId: actionRow.missionId,
|
| 332 |
+
routePlanId: actionRow.routePlanId,
|
| 333 |
+
actionType: actionRow.actionType as Action["actionType"],
|
| 334 |
+
title: actionRow.title,
|
| 335 |
+
description: actionRow.description,
|
| 336 |
+
target: actionRow.target as Record<string, unknown> | null,
|
| 337 |
+
inputPayload: actionRow.inputPayload as Record<string, unknown> | null,
|
| 338 |
+
canonicalPayloadHash: actionRow.canonicalPayloadHash,
|
| 339 |
+
consentRequired: actionRow.consentRequired,
|
| 340 |
+
consentGrantId: actionRow.consentGrantId,
|
| 341 |
+
status: actionRow.status as Action["status"],
|
| 342 |
+
proofState: actionRow.proofState as Action["proofState"],
|
| 343 |
+
proofRef: actionRow.proofRef as Record<string, unknown> | null,
|
| 344 |
+
idempotencyKey: actionRow.idempotencyKey,
|
| 345 |
+
riskLevel: actionRow.riskLevel as Action["riskLevel"],
|
| 346 |
+
createdBy: actionRow.createdBy as Action["createdBy"],
|
| 347 |
+
createdAt: actionRow.createdAt.toISOString(),
|
| 348 |
+
updatedAt: actionRow.updatedAt.toISOString(),
|
| 349 |
+
};
|
| 350 |
+
|
| 351 |
+
const latestAttemptDto: ActionAttemptDto | null = latestAttempt
|
| 352 |
+
? {
|
| 353 |
+
id: latestAttempt.id,
|
| 354 |
+
adapterName: latestAttempt.adapterName,
|
| 355 |
+
attemptNo: latestAttempt.attemptNo,
|
| 356 |
+
status: latestAttempt.status,
|
| 357 |
+
errorCode: latestAttempt.errorCode,
|
| 358 |
+
errorMessage: latestAttempt.errorMessage,
|
| 359 |
+
startedAt: latestAttempt.startedAt?.toISOString() ?? null,
|
| 360 |
+
finishedAt: latestAttempt.finishedAt?.toISOString() ?? null,
|
| 361 |
+
}
|
| 362 |
+
: null;
|
| 363 |
+
|
| 364 |
+
const submissions: SubmissionDto[] = submissionRows.map((s) => ({
|
| 365 |
+
id: s.id,
|
| 366 |
+
proofState: s.proofState as SubmissionDto["proofState"],
|
| 367 |
+
externalReference: s.externalReference,
|
| 368 |
+
sentAt: s.sentAt?.toISOString() ?? null,
|
| 369 |
+
}));
|
| 370 |
+
|
| 371 |
+
return {
|
| 372 |
+
action,
|
| 373 |
+
latestAttempt: latestAttemptDto,
|
| 374 |
+
submissions,
|
| 375 |
+
};
|
| 376 |
+
}
|
| 377 |
+
}
|
apps/api/src/app.module.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { ConversationsModule } from "./conversations/conversations.module.js";
|
|
| 6 |
import { WebhooksModule } from "./webhooks/webhooks.module.js";
|
| 7 |
import { EvidenceModule } from "./evidence/evidence.module.js";
|
| 8 |
import { RoutePlansModule } from "./route-plans/route-plans.module.js";
|
|
|
|
| 9 |
import { CapabilitiesController } from "./capabilities/capabilities.controller.js";
|
| 10 |
import { HealthController } from "./health.controller.js";
|
| 11 |
|
|
@@ -18,6 +19,7 @@ import { HealthController } from "./health.controller.js";
|
|
| 18 |
WebhooksModule,
|
| 19 |
EvidenceModule,
|
| 20 |
RoutePlansModule,
|
|
|
|
| 21 |
],
|
| 22 |
controllers: [CapabilitiesController, HealthController],
|
| 23 |
})
|
|
|
|
| 6 |
import { WebhooksModule } from "./webhooks/webhooks.module.js";
|
| 7 |
import { EvidenceModule } from "./evidence/evidence.module.js";
|
| 8 |
import { RoutePlansModule } from "./route-plans/route-plans.module.js";
|
| 9 |
+
import { ActionsModule } from "./actions/actions.module.js";
|
| 10 |
import { CapabilitiesController } from "./capabilities/capabilities.controller.js";
|
| 11 |
import { HealthController } from "./health.controller.js";
|
| 12 |
|
|
|
|
| 19 |
WebhooksModule,
|
| 20 |
EvidenceModule,
|
| 21 |
RoutePlansModule,
|
| 22 |
+
ActionsModule,
|
| 23 |
],
|
| 24 |
controllers: [CapabilitiesController, HealthController],
|
| 25 |
})
|
apps/api/src/workflow/inngest.module.ts
CHANGED
|
@@ -23,7 +23,11 @@ export class WorkflowService {
|
|
| 23 |
| "message.received"
|
| 24 |
| "evidence.uploaded"
|
| 25 |
| "mission.route_plan_requested"
|
| 26 |
-
| "mission.facts_confirmed"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
data: Record<string, string | boolean>,
|
| 28 |
): Promise<void> {
|
| 29 |
await this.inngest.send({ name, data });
|
|
|
|
| 23 |
| "message.received"
|
| 24 |
| "evidence.uploaded"
|
| 25 |
| "mission.route_plan_requested"
|
| 26 |
+
| "mission.facts_confirmed"
|
| 27 |
+
| "action.execute_requested"
|
| 28 |
+
| "action.executed"
|
| 29 |
+
| "action.failed"
|
| 30 |
+
| "mission.followup_due",
|
| 31 |
data: Record<string, string | boolean>,
|
| 32 |
): Promise<void> {
|
| 33 |
await this.inngest.send({ name, data });
|
apps/web/src/app/missions/[id]/page.tsx
CHANGED
|
@@ -2,13 +2,40 @@
|
|
| 2 |
|
| 3 |
import { use, useCallback, useEffect, useState } from "react";
|
| 4 |
import Link from "next/link";
|
| 5 |
-
import type {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import { api, type EvidenceItem, type FactsResponse, type RoutePlanDto } from "../../../lib/api";
|
| 7 |
|
| 8 |
// ---------------------------------------------------------------------------
|
| 9 |
// Support-level badge
|
| 10 |
// ---------------------------------------------------------------------------
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
function SupportBadge({ level }: { level: string }) {
|
| 13 |
const cls =
|
| 14 |
level === "explicit"
|
|
@@ -476,6 +503,10 @@ interface RoutePlanPanelProps {
|
|
| 476 |
triggering: boolean;
|
| 477 |
triggerError: string | null;
|
| 478 |
onTrigger: () => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
}
|
| 480 |
|
| 481 |
function RoutePlanPanel({
|
|
@@ -484,8 +515,14 @@ function RoutePlanPanel({
|
|
| 484 |
triggering,
|
| 485 |
triggerError,
|
| 486 |
onTrigger,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
}: RoutePlanPanelProps) {
|
| 488 |
const [expandedStep, setExpandedStep] = useState<number | null>(null);
|
|
|
|
|
|
|
| 489 |
|
| 490 |
if (loading) {
|
| 491 |
return (
|
|
@@ -705,6 +742,127 @@ function RoutePlanPanel({
|
|
| 705 |
Neutral guidance — not legal advice
|
| 706 |
</div>
|
| 707 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
</div>
|
| 709 |
)}
|
| 710 |
</div>
|
|
@@ -732,6 +890,7 @@ export default function MissionRoom({ params }: { params: Promise<{ id: string }
|
|
| 732 |
const [error, setError] = useState<string | null>(null);
|
| 733 |
const [confirming, setConfirming] = useState(false);
|
| 734 |
const [confirmMsg, setConfirmMsg] = useState<string | null>(null);
|
|
|
|
| 735 |
|
| 736 |
const load = useCallback(async () => {
|
| 737 |
setError(null);
|
|
@@ -798,6 +957,71 @@ export default function MissionRoom({ params }: { params: Promise<{ id: string }
|
|
| 798 |
}
|
| 799 |
}
|
| 800 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 801 |
if (error) return <div className="container"><div className="error">{error}</div></div>;
|
| 802 |
if (!mission) return <div className="container"><div className="muted">Loading…</div></div>;
|
| 803 |
|
|
@@ -912,6 +1136,10 @@ export default function MissionRoom({ params }: { params: Promise<{ id: string }
|
|
| 912 |
triggering={triggering}
|
| 913 |
triggerError={triggerError}
|
| 914 |
onTrigger={() => void handleTriggerRoutePlan()}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 915 |
/>
|
| 916 |
|
| 917 |
{/* ---------------------------------------------------------------- */}
|
|
|
|
| 2 |
|
| 3 |
import { use, useCallback, useEffect, useState } from "react";
|
| 4 |
import Link from "next/link";
|
| 5 |
+
import type {
|
| 6 |
+
Mission,
|
| 7 |
+
MissionEvent,
|
| 8 |
+
MissionStatusSnapshot,
|
| 9 |
+
ActionPreviewDto,
|
| 10 |
+
ConsentGrantResponse,
|
| 11 |
+
ExecuteActionResponse,
|
| 12 |
+
CreateActionPreviewInput,
|
| 13 |
+
} from "@courtmitra/contracts";
|
| 14 |
import { api, type EvidenceItem, type FactsResponse, type RoutePlanDto } from "../../../lib/api";
|
| 15 |
|
| 16 |
// ---------------------------------------------------------------------------
|
| 17 |
// Support-level badge
|
| 18 |
// ---------------------------------------------------------------------------
|
| 19 |
|
| 20 |
+
type PerStepActionState = {
|
| 21 |
+
preview: ActionPreviewDto | null;
|
| 22 |
+
consent: ConsentGrantResponse | null;
|
| 23 |
+
execution: ExecuteActionResponse | null;
|
| 24 |
+
error: string | null;
|
| 25 |
+
loading: boolean;
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
function ProofStateBadge({ state }: { state: string | null }) {
|
| 29 |
+
if (!state) return null;
|
| 30 |
+
const cls =
|
| 31 |
+
state === "sent_by_email"
|
| 32 |
+
? "badge badge-explicit"
|
| 33 |
+
: state === "failed"
|
| 34 |
+
? "badge badge-contradicted"
|
| 35 |
+
: "badge badge-weak";
|
| 36 |
+
return <span className={cls}>{state}</span>;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
function SupportBadge({ level }: { level: string }) {
|
| 40 |
const cls =
|
| 41 |
level === "explicit"
|
|
|
|
| 503 |
triggering: boolean;
|
| 504 |
triggerError: string | null;
|
| 505 |
onTrigger: () => void;
|
| 506 |
+
actionStates: Record<number, PerStepActionState>;
|
| 507 |
+
onPreviewAction: (stepNo: number, destination: string, body: string) => void;
|
| 508 |
+
onGrantConsent: (actionId: string) => void;
|
| 509 |
+
onExecuteAction: (actionId: string) => void;
|
| 510 |
}
|
| 511 |
|
| 512 |
function RoutePlanPanel({
|
|
|
|
| 515 |
triggering,
|
| 516 |
triggerError,
|
| 517 |
onTrigger,
|
| 518 |
+
actionStates,
|
| 519 |
+
onPreviewAction,
|
| 520 |
+
onGrantConsent,
|
| 521 |
+
onExecuteAction,
|
| 522 |
}: RoutePlanPanelProps) {
|
| 523 |
const [expandedStep, setExpandedStep] = useState<number | null>(null);
|
| 524 |
+
const [destinationInputs, setDestinationInputs] = useState<Record<number, string>>({});
|
| 525 |
+
const [bodyInputs, setBodyInputs] = useState<Record<number, string>>({});
|
| 526 |
|
| 527 |
if (loading) {
|
| 528 |
return (
|
|
|
|
| 742 |
Neutral guidance — not legal advice
|
| 743 |
</div>
|
| 744 |
)}
|
| 745 |
+
|
| 746 |
+
{/* Action preview / consent / send */}
|
| 747 |
+
<div style={{ marginTop: 16, borderTop: "1px solid var(--border)", paddingTop: 14 }}>
|
| 748 |
+
<div className="muted" style={{ fontSize: 12, marginBottom: 8 }}>
|
| 749 |
+
Action
|
| 750 |
+
</div>
|
| 751 |
+
<input
|
| 752 |
+
placeholder={`Email destination (default: ${step.authority ?? "authority"})`}
|
| 753 |
+
value={destinationInputs[step.stepNo] ?? ""}
|
| 754 |
+
onChange={(e) =>
|
| 755 |
+
setDestinationInputs((prev) => ({ ...prev, [step.stepNo]: e.target.value }))
|
| 756 |
+
}
|
| 757 |
+
style={{ marginBottom: 8 }}
|
| 758 |
+
/>
|
| 759 |
+
<textarea
|
| 760 |
+
placeholder="Action body (will be filled from template)"
|
| 761 |
+
value={bodyInputs[step.stepNo] ?? ""}
|
| 762 |
+
onChange={(e) =>
|
| 763 |
+
setBodyInputs((prev) => ({ ...prev, [step.stepNo]: e.target.value }))
|
| 764 |
+
}
|
| 765 |
+
style={{ minHeight: 80, marginBottom: 8, fontSize: 13 }}
|
| 766 |
+
/>
|
| 767 |
+
<button
|
| 768 |
+
className="btn-secondary btn-small"
|
| 769 |
+
disabled={actionStates[step.stepNo]?.loading}
|
| 770 |
+
onClick={(e) => {
|
| 771 |
+
e.stopPropagation();
|
| 772 |
+
onPreviewAction(
|
| 773 |
+
step.stepNo,
|
| 774 |
+
destinationInputs[step.stepNo] ?? step.authority ?? "",
|
| 775 |
+
bodyInputs[step.stepNo] ?? "",
|
| 776 |
+
);
|
| 777 |
+
}}
|
| 778 |
+
>
|
| 779 |
+
{actionStates[step.stepNo]?.loading ? "Generating…" : "Preview Action"}
|
| 780 |
+
</button>
|
| 781 |
+
|
| 782 |
+
{actionStates[step.stepNo]?.error && (
|
| 783 |
+
<div className="error" style={{ marginTop: 6 }}>
|
| 784 |
+
{actionStates[step.stepNo]!.error}
|
| 785 |
+
</div>
|
| 786 |
+
)}
|
| 787 |
+
|
| 788 |
+
{actionStates[step.stepNo]?.preview && !actionStates[step.stepNo]?.consent && (
|
| 789 |
+
<div
|
| 790 |
+
style={{
|
| 791 |
+
marginTop: 12,
|
| 792 |
+
padding: "10px 14px",
|
| 793 |
+
background: "var(--panel-2)",
|
| 794 |
+
border: "1px solid var(--border)",
|
| 795 |
+
borderRadius: 8,
|
| 796 |
+
}}
|
| 797 |
+
onClick={(e) => e.stopPropagation()}
|
| 798 |
+
>
|
| 799 |
+
<div className="muted" style={{ fontSize: 11, marginBottom: 6 }}>
|
| 800 |
+
Preview — hash:{" "}
|
| 801 |
+
{actionStates[step.stepNo]!.preview!.canonicalPayloadHash.slice(0, 16)}…
|
| 802 |
+
</div>
|
| 803 |
+
<pre
|
| 804 |
+
style={{
|
| 805 |
+
fontSize: 12,
|
| 806 |
+
whiteSpace: "pre-wrap",
|
| 807 |
+
wordBreak: "break-word",
|
| 808 |
+
maxHeight: 120,
|
| 809 |
+
overflow: "auto",
|
| 810 |
+
margin: 0,
|
| 811 |
+
color: "var(--muted)",
|
| 812 |
+
}}
|
| 813 |
+
>
|
| 814 |
+
{actionStates[step.stepNo]!.preview!.preview.bodyPreview}
|
| 815 |
+
</pre>
|
| 816 |
+
<label style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 10, cursor: "pointer" }}>
|
| 817 |
+
<input type="checkbox" id={`consent-${step.stepNo}`} style={{ width: "auto" }} />
|
| 818 |
+
<span style={{ fontSize: 13, color: "var(--text)" }}>
|
| 819 |
+
I approve this action
|
| 820 |
+
</span>
|
| 821 |
+
</label>
|
| 822 |
+
<button
|
| 823 |
+
style={{ marginTop: 8 }}
|
| 824 |
+
disabled={actionStates[step.stepNo]?.loading}
|
| 825 |
+
onClick={() => {
|
| 826 |
+
const cb = document.getElementById(`consent-${step.stepNo}`) as HTMLInputElement | null;
|
| 827 |
+
if (!cb?.checked) return;
|
| 828 |
+
onGrantConsent(actionStates[step.stepNo]!.preview!.action.id);
|
| 829 |
+
}}
|
| 830 |
+
>
|
| 831 |
+
Approve & Send
|
| 832 |
+
</button>
|
| 833 |
+
</div>
|
| 834 |
+
)}
|
| 835 |
+
|
| 836 |
+
{actionStates[step.stepNo]?.consent && !actionStates[step.stepNo]?.execution && (
|
| 837 |
+
<div style={{ marginTop: 10 }} onClick={(e) => e.stopPropagation()}>
|
| 838 |
+
<button
|
| 839 |
+
disabled={actionStates[step.stepNo]?.loading}
|
| 840 |
+
onClick={() =>
|
| 841 |
+
onExecuteAction(actionStates[step.stepNo]!.preview!.action.id)
|
| 842 |
+
}
|
| 843 |
+
>
|
| 844 |
+
{actionStates[step.stepNo]?.loading ? "Sending…" : "Send Now"}
|
| 845 |
+
</button>
|
| 846 |
+
</div>
|
| 847 |
+
)}
|
| 848 |
+
|
| 849 |
+
{actionStates[step.stepNo]?.execution && (
|
| 850 |
+
<div style={{ marginTop: 8 }} onClick={(e) => e.stopPropagation()}>
|
| 851 |
+
<ProofStateBadge
|
| 852 |
+
state={
|
| 853 |
+
(actionStates[step.stepNo]!.execution as ExecuteActionResponse & { refused?: boolean }).refused
|
| 854 |
+
? null
|
| 855 |
+
: (actionStates[step.stepNo]!.execution as ExecuteActionResponse).proofState
|
| 856 |
+
}
|
| 857 |
+
/>
|
| 858 |
+
{actionStates[step.stepNo]!.execution && "refused" in actionStates[step.stepNo]!.execution! && (
|
| 859 |
+
<div className="error" style={{ marginTop: 4 }}>
|
| 860 |
+
{(actionStates[step.stepNo]!.execution as { reason?: string }).reason ?? "Execution refused"}
|
| 861 |
+
</div>
|
| 862 |
+
)}
|
| 863 |
+
</div>
|
| 864 |
+
)}
|
| 865 |
+
</div>
|
| 866 |
</div>
|
| 867 |
)}
|
| 868 |
</div>
|
|
|
|
| 890 |
const [error, setError] = useState<string | null>(null);
|
| 891 |
const [confirming, setConfirming] = useState(false);
|
| 892 |
const [confirmMsg, setConfirmMsg] = useState<string | null>(null);
|
| 893 |
+
const [actionStates, setActionStates] = useState<Record<number, PerStepActionState>>({});
|
| 894 |
|
| 895 |
const load = useCallback(async () => {
|
| 896 |
setError(null);
|
|
|
|
| 957 |
}
|
| 958 |
}
|
| 959 |
|
| 960 |
+
function updateActionState(
|
| 961 |
+
stepNoOrActionId: number | string,
|
| 962 |
+
patch: Partial<PerStepActionState>,
|
| 963 |
+
) {
|
| 964 |
+
setActionStates((prev) => {
|
| 965 |
+
const stepNo =
|
| 966 |
+
typeof stepNoOrActionId === "number"
|
| 967 |
+
? stepNoOrActionId
|
| 968 |
+
: Number(
|
| 969 |
+
Object.keys(prev).find(
|
| 970 |
+
(k) => prev[Number(k)]?.preview?.action.id === stepNoOrActionId,
|
| 971 |
+
),
|
| 972 |
+
);
|
| 973 |
+
return { ...prev, [stepNo]: { ...(prev[stepNo] ?? emptyActionState()), ...patch } };
|
| 974 |
+
});
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
function emptyActionState(): PerStepActionState {
|
| 978 |
+
return { preview: null, consent: null, execution: null, error: null, loading: false };
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
+
async function handlePreviewAction(stepNo: number, destination: string, body: string) {
|
| 982 |
+
if (!routePlan || !mission) return;
|
| 983 |
+
const step = routePlan.steps.find((s) => s.stepNo === stepNo);
|
| 984 |
+
if (!step) return;
|
| 985 |
+
const routePlanId = mission.currentRoutePlanId ?? routePlan.missionId;
|
| 986 |
+
updateActionState(stepNo, { preview: null, consent: null, execution: null, error: null, loading: true });
|
| 987 |
+
try {
|
| 988 |
+
const input: CreateActionPreviewInput = {
|
| 989 |
+
routePlanId,
|
| 990 |
+
stepNo,
|
| 991 |
+
destination,
|
| 992 |
+
subject: `Complaint regarding ${step.title}`,
|
| 993 |
+
body,
|
| 994 |
+
attachments: [],
|
| 995 |
+
piiDisclosed: [],
|
| 996 |
+
risks: [],
|
| 997 |
+
};
|
| 998 |
+
const result = await api.previewAction(id, input);
|
| 999 |
+
updateActionState(stepNo, { preview: result, loading: false });
|
| 1000 |
+
} catch (e) {
|
| 1001 |
+
updateActionState(stepNo, { error: String(e), loading: false });
|
| 1002 |
+
}
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
async function handleGrantConsent(actionId: string) {
|
| 1006 |
+
updateActionState(actionId, { loading: true, error: null });
|
| 1007 |
+
try {
|
| 1008 |
+
const consent = await api.grantConsent(actionId);
|
| 1009 |
+
updateActionState(actionId, { consent, loading: false });
|
| 1010 |
+
} catch (e) {
|
| 1011 |
+
updateActionState(actionId, { error: String(e), loading: false });
|
| 1012 |
+
}
|
| 1013 |
+
}
|
| 1014 |
+
|
| 1015 |
+
async function handleExecuteAction(actionId: string) {
|
| 1016 |
+
updateActionState(actionId, { loading: true, error: null });
|
| 1017 |
+
try {
|
| 1018 |
+
const execution = await api.executeAction(actionId);
|
| 1019 |
+
updateActionState(actionId, { execution, loading: false });
|
| 1020 |
+
} catch (e) {
|
| 1021 |
+
updateActionState(actionId, { error: String(e), loading: false });
|
| 1022 |
+
}
|
| 1023 |
+
}
|
| 1024 |
+
|
| 1025 |
if (error) return <div className="container"><div className="error">{error}</div></div>;
|
| 1026 |
if (!mission) return <div className="container"><div className="muted">Loading…</div></div>;
|
| 1027 |
|
|
|
|
| 1136 |
triggering={triggering}
|
| 1137 |
triggerError={triggerError}
|
| 1138 |
onTrigger={() => void handleTriggerRoutePlan()}
|
| 1139 |
+
actionStates={actionStates}
|
| 1140 |
+
onPreviewAction={(stepNo, dest, body) => void handlePreviewAction(stepNo, dest, body)}
|
| 1141 |
+
onGrantConsent={(actionId) => void handleGrantConsent(actionId)}
|
| 1142 |
+
onExecuteAction={(actionId) => void handleExecuteAction(actionId)}
|
| 1143 |
/>
|
| 1144 |
|
| 1145 |
{/* ---------------------------------------------------------------- */}
|
apps/web/src/app/p/[slug]/page.tsx
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { notFound } from "next/navigation";
|
| 2 |
+
import type { Metadata } from "next";
|
| 3 |
+
|
| 4 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001/api";
|
| 5 |
+
|
| 6 |
+
export const metadata: Metadata = {
|
| 7 |
+
robots: "noindex, nofollow",
|
| 8 |
+
title: "Case page — CourtMitra",
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
interface PublicPageResponse {
|
| 12 |
+
page: {
|
| 13 |
+
id: string;
|
| 14 |
+
missionId: string;
|
| 15 |
+
slug: string;
|
| 16 |
+
visibility: string;
|
| 17 |
+
redactedSummary: string | null;
|
| 18 |
+
createdAt: string;
|
| 19 |
+
};
|
| 20 |
+
mission: {
|
| 21 |
+
title: string;
|
| 22 |
+
issueDomain: string;
|
| 23 |
+
state: string;
|
| 24 |
+
} | null;
|
| 25 |
+
actions: Array<{
|
| 26 |
+
id: string;
|
| 27 |
+
actionType: string;
|
| 28 |
+
title: string;
|
| 29 |
+
status: string;
|
| 30 |
+
proofState: string | null;
|
| 31 |
+
createdAt: string;
|
| 32 |
+
}>;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function ProofStateBadge({ state }: { state: string | null }) {
|
| 36 |
+
if (!state) return null;
|
| 37 |
+
const cls =
|
| 38 |
+
state === "sent_by_email"
|
| 39 |
+
? "badge badge-explicit"
|
| 40 |
+
: state === "failed"
|
| 41 |
+
? "badge badge-contradicted"
|
| 42 |
+
: "badge badge-weak";
|
| 43 |
+
return <span className={cls}>{state}</span>;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export default async function PublicCasePage({
|
| 47 |
+
params,
|
| 48 |
+
}: {
|
| 49 |
+
params: Promise<{ slug: string }>;
|
| 50 |
+
}) {
|
| 51 |
+
const { slug } = await params;
|
| 52 |
+
|
| 53 |
+
let data: PublicPageResponse;
|
| 54 |
+
try {
|
| 55 |
+
const res = await fetch(`${API_BASE}/public-pages/${slug}`, {
|
| 56 |
+
cache: "no-store",
|
| 57 |
+
});
|
| 58 |
+
if (!res.ok) {
|
| 59 |
+
if (res.status === 404) notFound();
|
| 60 |
+
throw new Error(`Failed to load: ${res.status}`);
|
| 61 |
+
}
|
| 62 |
+
data = (await res.json()) as PublicPageResponse;
|
| 63 |
+
} catch {
|
| 64 |
+
notFound();
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
return (
|
| 68 |
+
<div className="container">
|
| 69 |
+
<meta name="robots" content="noindex,nofollow" />
|
| 70 |
+
<div className="header">
|
| 71 |
+
<div>
|
| 72 |
+
<div className="brand">Case page</div>
|
| 73 |
+
<div className="muted" style={{ marginTop: 4 }}>
|
| 74 |
+
CourtMitra — legal information & workflow assistance only
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div className="card">
|
| 80 |
+
<div className="muted" style={{ fontSize: 12, marginBottom: 6 }}>
|
| 81 |
+
Status
|
| 82 |
+
</div>
|
| 83 |
+
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
| 84 |
+
<span className="pill domain">
|
| 85 |
+
{data.mission?.issueDomain ?? "unresolved"}
|
| 86 |
+
</span>
|
| 87 |
+
<span className="pill">{data.mission?.state ?? "unknown"}</span>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<div className="card">
|
| 92 |
+
<div className="muted" style={{ fontSize: 12, marginBottom: 6 }}>
|
| 93 |
+
Subject
|
| 94 |
+
</div>
|
| 95 |
+
<div style={{ lineHeight: 1.6, fontSize: 14 }}>
|
| 96 |
+
User reports / unresolved / alleged issue
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div className="card">
|
| 101 |
+
<div className="muted" style={{ fontSize: 12, marginBottom: 6 }}>
|
| 102 |
+
Summary
|
| 103 |
+
</div>
|
| 104 |
+
<div style={{ lineHeight: 1.6, fontSize: 14, whiteSpace: "pre-wrap" }}>
|
| 105 |
+
{data.page.redactedSummary ?? "No summary available."}
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<h3 className="muted" style={{ marginTop: 24, marginBottom: 12 }}>
|
| 110 |
+
Actions
|
| 111 |
+
</h3>
|
| 112 |
+
{data.actions.length === 0 ? (
|
| 113 |
+
<div className="card">
|
| 114 |
+
<div className="empty-state">No actions recorded yet.</div>
|
| 115 |
+
</div>
|
| 116 |
+
) : (
|
| 117 |
+
data.actions.map((action) => (
|
| 118 |
+
<div
|
| 119 |
+
key={action.id}
|
| 120 |
+
className="card"
|
| 121 |
+
style={{ marginBottom: 10 }}
|
| 122 |
+
>
|
| 123 |
+
<div style={{ fontWeight: 600, fontSize: 14 }}>{action.title}</div>
|
| 124 |
+
<div style={{ display: "flex", gap: 8, marginTop: 6, flexWrap: "wrap" }}>
|
| 125 |
+
<span className="pill">{action.actionType}</span>
|
| 126 |
+
<span className="pill">{action.status}</span>
|
| 127 |
+
<ProofStateBadge state={action.proofState} />
|
| 128 |
+
</div>
|
| 129 |
+
<div className="muted" style={{ marginTop: 6, fontSize: 12 }}>
|
| 130 |
+
{new Date(action.createdAt).toLocaleString()}
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
))
|
| 134 |
+
)}
|
| 135 |
+
|
| 136 |
+
<div
|
| 137 |
+
style={{
|
| 138 |
+
marginTop: 32,
|
| 139 |
+
padding: "14px 18px",
|
| 140 |
+
background: "var(--panel-2)",
|
| 141 |
+
border: "1px solid var(--border)",
|
| 142 |
+
borderRadius: 10,
|
| 143 |
+
fontSize: 12,
|
| 144 |
+
color: "var(--muted)",
|
| 145 |
+
lineHeight: 1.7,
|
| 146 |
+
}}
|
| 147 |
+
>
|
| 148 |
+
Legal information & workflow assistance only — not a lawyer-client
|
| 149 |
+
relationship. CourtMitra does not guarantee outcomes. Entity labels use
|
| 150 |
+
“user reports / unresolved / alleged” wording only.
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
);
|
| 154 |
+
}
|
apps/web/src/lib/api.ts
CHANGED
|
@@ -7,6 +7,11 @@ import type {
|
|
| 7 |
ParsedEvidenceDto,
|
| 8 |
EvidenceClaimDto,
|
| 9 |
RoutePlanDto,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
} from "@courtmitra/contracts";
|
| 11 |
|
| 12 |
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001/api";
|
|
@@ -116,4 +121,28 @@ export const api = {
|
|
| 116 |
headers: { "idempotency-key": crypto.randomUUID() },
|
| 117 |
body: JSON.stringify({}),
|
| 118 |
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
};
|
|
|
|
| 7 |
ParsedEvidenceDto,
|
| 8 |
EvidenceClaimDto,
|
| 9 |
RoutePlanDto,
|
| 10 |
+
CreateActionPreviewInput,
|
| 11 |
+
ActionPreviewDto,
|
| 12 |
+
ConsentGrantResponse,
|
| 13 |
+
ExecuteActionResponse,
|
| 14 |
+
ActionDetailDto,
|
| 15 |
} from "@courtmitra/contracts";
|
| 16 |
|
| 17 |
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001/api";
|
|
|
|
| 121 |
headers: { "idempotency-key": crypto.randomUUID() },
|
| 122 |
body: JSON.stringify({}),
|
| 123 |
}),
|
| 124 |
+
|
| 125 |
+
// --- Action endpoints ---
|
| 126 |
+
previewAction: (missionId: string, input: CreateActionPreviewInput) =>
|
| 127 |
+
http<ActionPreviewDto>(`/missions/${missionId}/actions/preview`, {
|
| 128 |
+
method: "POST",
|
| 129 |
+
headers: { "idempotency-key": crypto.randomUUID() },
|
| 130 |
+
body: JSON.stringify(input),
|
| 131 |
+
}),
|
| 132 |
+
|
| 133 |
+
grantConsent: (actionId: string) =>
|
| 134 |
+
http<ConsentGrantResponse>(`/actions/${actionId}/consent`, {
|
| 135 |
+
method: "POST",
|
| 136 |
+
body: JSON.stringify({}),
|
| 137 |
+
}),
|
| 138 |
+
|
| 139 |
+
executeAction: (actionId: string) =>
|
| 140 |
+
http<ExecuteActionResponse>(`/actions/${actionId}/execute`, {
|
| 141 |
+
method: "POST",
|
| 142 |
+
headers: { "idempotency-key": crypto.randomUUID() },
|
| 143 |
+
body: JSON.stringify({}),
|
| 144 |
+
}),
|
| 145 |
+
|
| 146 |
+
getActionDetail: (actionId: string) =>
|
| 147 |
+
http<ActionDetailDto>(`/actions/${actionId}`),
|
| 148 |
};
|
apps/worker/src/functions/action-execute.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Inngest, type InngestFunction } from "inngest";
|
| 2 |
+
import { db } from "../deps.js";
|
| 3 |
+
import { executeAction } from "../lib/action-executor.js";
|
| 4 |
+
import { createResendEmailAdapter, getCapabilities } from "@courtmitra/adapters";
|
| 5 |
+
import type { ExecuteActionResponse, ActionExecutionRefused } from "@courtmitra/contracts";
|
| 6 |
+
|
| 7 |
+
export function createActionExecute(inngest: Inngest): InngestFunction.Any {
|
| 8 |
+
return inngest.createFunction(
|
| 9 |
+
{
|
| 10 |
+
id: "action-execute",
|
| 11 |
+
name: "Execute Action",
|
| 12 |
+
triggers: [{ event: "action.execute_requested" }],
|
| 13 |
+
},
|
| 14 |
+
async ({ event, step }) => {
|
| 15 |
+
const { actionId, missionId } = event.data as {
|
| 16 |
+
actionId: string;
|
| 17 |
+
missionId: string;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
const result = await step.run("execute-action", async () => {
|
| 21 |
+
const emailAdapter = createResendEmailAdapter();
|
| 22 |
+
const capabilities = getCapabilities();
|
| 23 |
+
return executeAction(actionId, { db, emailAdapter, capabilities });
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
await step.run("emit-execution-event", async () => {
|
| 27 |
+
if ("refused" in result && (result as ActionExecutionRefused).refused) {
|
| 28 |
+
await inngest.send({
|
| 29 |
+
name: "action.failed" as const,
|
| 30 |
+
data: { actionId, missionId, error: (result as ActionExecutionRefused).reason },
|
| 31 |
+
});
|
| 32 |
+
return;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const execResult = result as ExecuteActionResponse;
|
| 36 |
+
await inngest.send({
|
| 37 |
+
name: "action.executed" as const,
|
| 38 |
+
data: { actionId, missionId, proofState: execResult.proofState },
|
| 39 |
+
});
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
return result;
|
| 43 |
+
},
|
| 44 |
+
) as InngestFunction.Any;
|
| 45 |
+
}
|
apps/worker/src/functions/followup-schedule.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { eq, desc, and } from "drizzle-orm";
|
| 2 |
+
import { Inngest, type InngestFunction } from "inngest";
|
| 3 |
+
import { db } from "../deps.js";
|
| 4 |
+
import { schema } from "@courtmitra/db";
|
| 5 |
+
|
| 6 |
+
export function createFollowupSchedule(inngest: Inngest): InngestFunction.Any {
|
| 7 |
+
return inngest.createFunction(
|
| 8 |
+
{
|
| 9 |
+
id: "followup-schedule",
|
| 10 |
+
name: "Schedule Follow-up",
|
| 11 |
+
triggers: [{ event: "action.executed" }],
|
| 12 |
+
},
|
| 13 |
+
async ({ event, step }) => {
|
| 14 |
+
const { missionId, actionId, proofState } = event.data as {
|
| 15 |
+
missionId: string;
|
| 16 |
+
actionId: string;
|
| 17 |
+
proofState: string;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
if (proofState !== "sent_by_email") {
|
| 21 |
+
return { skipped: true, reason: "proofState is not sent_by_email" };
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const sleepUntil = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
| 25 |
+
await step.sleepUntil("wait-for-followup", sleepUntil);
|
| 26 |
+
|
| 27 |
+
await step.run("mark-followup-due", async () => {
|
| 28 |
+
const [followup] = await db
|
| 29 |
+
.select()
|
| 30 |
+
.from(schema.followups)
|
| 31 |
+
.where(
|
| 32 |
+
and(
|
| 33 |
+
eq(schema.followups.missionId, missionId),
|
| 34 |
+
eq(schema.followups.type, "email_followup"),
|
| 35 |
+
eq(schema.followups.status, "scheduled"),
|
| 36 |
+
),
|
| 37 |
+
)
|
| 38 |
+
.orderBy(desc(schema.followups.createdAt))
|
| 39 |
+
.limit(1);
|
| 40 |
+
|
| 41 |
+
if (followup) {
|
| 42 |
+
await db
|
| 43 |
+
.update(schema.followups)
|
| 44 |
+
.set({ status: "due" })
|
| 45 |
+
.where(eq(schema.followups.id, followup.id));
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
await db.insert(schema.missionEvents).values({
|
| 49 |
+
missionId,
|
| 50 |
+
eventType: "followup_due",
|
| 51 |
+
actorType: "system",
|
| 52 |
+
payload: { actionId },
|
| 53 |
+
});
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
await step.run("emit-followup-event", async () => {
|
| 57 |
+
await inngest.send({
|
| 58 |
+
name: "mission.followup_due" as const,
|
| 59 |
+
data: { missionId, actionId },
|
| 60 |
+
});
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
return { missionId, actionId, followupScheduled: true };
|
| 64 |
+
},
|
| 65 |
+
) as InngestFunction.Any;
|
| 66 |
+
}
|
apps/worker/src/inngest.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { Inngest, type InngestFunction } from "inngest";
|
|
| 2 |
import { createRunIntake } from "./functions/intake.js";
|
| 3 |
import { createParseEvidence } from "./functions/evidence.js";
|
| 4 |
import { createRunRoutePlan } from "./functions/route-plan.js";
|
|
|
|
|
|
|
| 5 |
|
| 6 |
/**
|
| 7 |
* Inngest client. Phase-1 functions (intake, evidence parse, legal grounding,
|
|
@@ -15,4 +17,6 @@ export const functions: InngestFunction.Any[] = [
|
|
| 15 |
createRunIntake(inngest),
|
| 16 |
createParseEvidence(inngest),
|
| 17 |
createRunRoutePlan(inngest),
|
|
|
|
|
|
|
| 18 |
];
|
|
|
|
| 2 |
import { createRunIntake } from "./functions/intake.js";
|
| 3 |
import { createParseEvidence } from "./functions/evidence.js";
|
| 4 |
import { createRunRoutePlan } from "./functions/route-plan.js";
|
| 5 |
+
import { createActionExecute } from "./functions/action-execute.js";
|
| 6 |
+
import { createFollowupSchedule } from "./functions/followup-schedule.js";
|
| 7 |
|
| 8 |
/**
|
| 9 |
* Inngest client. Phase-1 functions (intake, evidence parse, legal grounding,
|
|
|
|
| 17 |
createRunIntake(inngest),
|
| 18 |
createParseEvidence(inngest),
|
| 19 |
createRunRoutePlan(inngest),
|
| 20 |
+
createActionExecute(inngest),
|
| 21 |
+
createFollowupSchedule(inngest),
|
| 22 |
];
|
apps/worker/src/lib/action-executor.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { eq, desc } from "drizzle-orm";
|
| 2 |
+
import { createHash } from "node:crypto";
|
| 3 |
+
import { schema } from "@courtmitra/db";
|
| 4 |
+
import { canExecute } from "@courtmitra/domain";
|
| 5 |
+
import type { Database } from "@courtmitra/db";
|
| 6 |
+
import type { SubmissionPort } from "@courtmitra/ports";
|
| 7 |
+
import type {
|
| 8 |
+
AdapterCapability,
|
| 9 |
+
ConsentPreview,
|
| 10 |
+
ExecuteActionResponse,
|
| 11 |
+
ActionExecutionRefused,
|
| 12 |
+
Action,
|
| 13 |
+
} from "@courtmitra/contracts";
|
| 14 |
+
|
| 15 |
+
function computeHash(obj: unknown): string {
|
| 16 |
+
return "sha256:" + createHash("sha256").update(JSON.stringify(obj)).digest("hex");
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const STATE_RANK: Record<string, number> = {
|
| 20 |
+
created: 0,
|
| 21 |
+
intake_in_progress: 1,
|
| 22 |
+
needs_user_input: 2,
|
| 23 |
+
evidence_processing: 3,
|
| 24 |
+
evidence_ready: 4,
|
| 25 |
+
facts_pending_confirmation: 5,
|
| 26 |
+
facts_confirmed: 6,
|
| 27 |
+
legal_grounding_in_progress: 7,
|
| 28 |
+
route_plan_ready: 8,
|
| 29 |
+
actions_preview_ready: 9,
|
| 30 |
+
actions_awaiting_consent: 10,
|
| 31 |
+
action_executing: 11,
|
| 32 |
+
waiting_for_response: 12,
|
| 33 |
+
followup_due: 13,
|
| 34 |
+
escalation_ready: 14,
|
| 35 |
+
human_review_required: 15,
|
| 36 |
+
resolver_reply_received: 16,
|
| 37 |
+
resolution_claimed: 17,
|
| 38 |
+
resolved: 18,
|
| 39 |
+
closed_unresolved: 19,
|
| 40 |
+
paused: 20,
|
| 41 |
+
blocked: 21,
|
| 42 |
+
failed: 22,
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
export async function executeAction(
|
| 46 |
+
actionId: string,
|
| 47 |
+
opts: { db: Database; emailAdapter: SubmissionPort; capabilities: AdapterCapability[] },
|
| 48 |
+
): Promise<ExecuteActionResponse | ActionExecutionRefused> {
|
| 49 |
+
const { db: txDb, emailAdapter, capabilities } = opts;
|
| 50 |
+
|
| 51 |
+
const [actionRow] = await txDb
|
| 52 |
+
.select()
|
| 53 |
+
.from(schema.actions)
|
| 54 |
+
.where(eq(schema.actions.id, actionId))
|
| 55 |
+
.limit(1);
|
| 56 |
+
|
| 57 |
+
if (!actionRow) {
|
| 58 |
+
return { refused: true, reason: "Action not found", code: "consent_not_found" } satisfies ActionExecutionRefused;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const action = actionRow as unknown as Action;
|
| 62 |
+
|
| 63 |
+
let consentGrant: { actionPreviewHash: string } | null = null;
|
| 64 |
+
if (action.consentGrantId) {
|
| 65 |
+
const [grantRow] = await txDb
|
| 66 |
+
.select({ actionPreviewHash: schema.consentGrants.actionPreviewHash })
|
| 67 |
+
.from(schema.consentGrants)
|
| 68 |
+
.where(eq(schema.consentGrants.id, action.consentGrantId))
|
| 69 |
+
.limit(1);
|
| 70 |
+
|
| 71 |
+
if (grantRow) {
|
| 72 |
+
consentGrant = grantRow;
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
const inputPayload = action.inputPayload ?? {};
|
| 77 |
+
const preview: ConsentPreview = {
|
| 78 |
+
actionType: action.actionType,
|
| 79 |
+
destination: (inputPayload as any)?.destination ?? "",
|
| 80 |
+
public: false,
|
| 81 |
+
body: (inputPayload as any)?.body ?? "",
|
| 82 |
+
attachments: (inputPayload as any)?.attachments ?? [],
|
| 83 |
+
piiDisclosed: (inputPayload as any)?.piiDisclosed ?? [],
|
| 84 |
+
risks: (inputPayload as any)?.risks ?? [],
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
const check = canExecute(action, consentGrant, preview, capabilities);
|
| 88 |
+
if (check.refused) {
|
| 89 |
+
return check satisfies ActionExecutionRefused;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const result = await txDb.transaction(async (tx) => {
|
| 93 |
+
const [attempt] = await tx
|
| 94 |
+
.insert(schema.actionAttempts)
|
| 95 |
+
.values({
|
| 96 |
+
actionId,
|
| 97 |
+
adapterName: "resend_email",
|
| 98 |
+
attemptNo: 1,
|
| 99 |
+
requestPayloadHash: computeHash(action.inputPayload),
|
| 100 |
+
status: "started",
|
| 101 |
+
startedAt: new Date(),
|
| 102 |
+
})
|
| 103 |
+
.returning({ id: schema.actionAttempts.id });
|
| 104 |
+
|
| 105 |
+
if (!attempt) {
|
| 106 |
+
throw new Error("Failed to create action attempt record");
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const submissionResult = await emailAdapter.submit({
|
| 110 |
+
missionId: action.missionId,
|
| 111 |
+
actionId,
|
| 112 |
+
destination: (inputPayload as any)?.destination ?? "",
|
| 113 |
+
subject: (inputPayload as any)?.subject,
|
| 114 |
+
body: (inputPayload as any)?.body ?? "",
|
| 115 |
+
attachments: ((inputPayload as any)?.attachments ?? []).map((a: any) => ({
|
| 116 |
+
id: a.id ?? "",
|
| 117 |
+
storageKey: a.sha256 ?? "",
|
| 118 |
+
filename: a.id ?? "attachment",
|
| 119 |
+
})),
|
| 120 |
+
idempotencyKey: action.idempotencyKey ?? `action-${actionId}`,
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
const succeeded = submissionResult.proofState !== "failed";
|
| 124 |
+
|
| 125 |
+
await tx
|
| 126 |
+
.update(schema.actionAttempts)
|
| 127 |
+
.set({
|
| 128 |
+
status: succeeded ? "succeeded" : "failed",
|
| 129 |
+
responsePayload: submissionResult.proofRef,
|
| 130 |
+
providerReference: submissionResult.externalReference,
|
| 131 |
+
finishedAt: new Date(),
|
| 132 |
+
})
|
| 133 |
+
.where(eq(schema.actionAttempts.id, attempt.id));
|
| 134 |
+
|
| 135 |
+
await tx
|
| 136 |
+
.update(schema.actions)
|
| 137 |
+
.set({
|
| 138 |
+
proofState: submissionResult.proofState,
|
| 139 |
+
status: succeeded ? "succeeded" : "failed",
|
| 140 |
+
updatedAt: new Date(),
|
| 141 |
+
})
|
| 142 |
+
.where(eq(schema.actions.id, actionId));
|
| 143 |
+
|
| 144 |
+
await tx.insert(schema.submissions).values({
|
| 145 |
+
missionId: action.missionId,
|
| 146 |
+
actionId,
|
| 147 |
+
adapterName: "resend_email",
|
| 148 |
+
status: succeeded ? "sent" : "failed",
|
| 149 |
+
proofState: submissionResult.proofState,
|
| 150 |
+
externalReference: submissionResult.externalReference,
|
| 151 |
+
payloadHash: action.canonicalPayloadHash,
|
| 152 |
+
sentAt: new Date(),
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
await tx.insert(schema.missionEvents).values({
|
| 156 |
+
missionId: action.missionId,
|
| 157 |
+
eventType: succeeded ? "action_executed" : "action_execution_failed",
|
| 158 |
+
actorType: "system",
|
| 159 |
+
payload: {
|
| 160 |
+
actionId,
|
| 161 |
+
proofState: submissionResult.proofState,
|
| 162 |
+
externalReference: submissionResult.externalReference,
|
| 163 |
+
},
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
const newState = succeeded ? "waiting_for_response" : "failed";
|
| 167 |
+
const [latestSnapshot] = await tx
|
| 168 |
+
.select({ state: schema.missionStatusSnapshots.state })
|
| 169 |
+
.from(schema.missionStatusSnapshots)
|
| 170 |
+
.where(eq(schema.missionStatusSnapshots.missionId, action.missionId))
|
| 171 |
+
.orderBy(desc(schema.missionStatusSnapshots.createdAt))
|
| 172 |
+
.limit(1);
|
| 173 |
+
|
| 174 |
+
const currentRank = STATE_RANK[latestSnapshot?.state ?? ""] ?? -1;
|
| 175 |
+
const newRank = STATE_RANK[newState] ?? 0;
|
| 176 |
+
|
| 177 |
+
if (newRank >= currentRank) {
|
| 178 |
+
await tx.insert(schema.missionStatusSnapshots).values({
|
| 179 |
+
missionId: action.missionId,
|
| 180 |
+
state: newState,
|
| 181 |
+
headline: succeeded
|
| 182 |
+
? `Action "${action.title}" sent — waiting for response.`
|
| 183 |
+
: `Action "${action.title}" failed to send.`,
|
| 184 |
+
nextUserAction: succeeded
|
| 185 |
+
? "Check for reply on the sent complaint."
|
| 186 |
+
: "Review the error and retry.",
|
| 187 |
+
nextSystemAction: succeeded
|
| 188 |
+
? "Schedule follow-up reminder."
|
| 189 |
+
: "None — action failed.",
|
| 190 |
+
primaryButton: succeeded ? "View sent action" : "Retry",
|
| 191 |
+
danger: !succeeded,
|
| 192 |
+
});
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
if (succeeded) {
|
| 196 |
+
await tx.insert(schema.followups).values({
|
| 197 |
+
missionId: action.missionId,
|
| 198 |
+
type: "email_followup",
|
| 199 |
+
dueAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
| 200 |
+
status: "scheduled",
|
| 201 |
+
payload: { actionId },
|
| 202 |
+
});
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
return {
|
| 206 |
+
actionId,
|
| 207 |
+
proofState: submissionResult.proofState,
|
| 208 |
+
status: succeeded ? "succeeded" : "failed",
|
| 209 |
+
attempt: {
|
| 210 |
+
id: attempt.id,
|
| 211 |
+
attemptNo: 1,
|
| 212 |
+
status: succeeded ? "succeeded" : "failed",
|
| 213 |
+
},
|
| 214 |
+
} satisfies ExecuteActionResponse;
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
return result;
|
| 218 |
+
}
|
packages/adapters/package.json
CHANGED
|
@@ -21,6 +21,7 @@
|
|
| 21 |
"@openrouter/ai-sdk-provider": "^2.9.0",
|
| 22 |
"ai": "^6.0.191",
|
| 23 |
"drizzle-orm": "^0.45.2",
|
|
|
|
| 24 |
"zod": "^4.4.3"
|
| 25 |
},
|
| 26 |
"devDependencies": {
|
|
|
|
| 21 |
"@openrouter/ai-sdk-provider": "^2.9.0",
|
| 22 |
"ai": "^6.0.191",
|
| 23 |
"drizzle-orm": "^0.45.2",
|
| 24 |
+
"resend": "^6.12.4",
|
| 25 |
"zod": "^4.4.3"
|
| 26 |
},
|
| 27 |
"devDependencies": {
|
packages/adapters/src/__tests__/regex-redaction.test.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from "vitest";
|
| 2 |
+
import { createRegexRedaction, RegexRedactionAdapter } from "../redaction/regex.js";
|
| 3 |
+
|
| 4 |
+
describe("RegexRedactionAdapter", () => {
|
| 5 |
+
const adapter: RegexRedactionAdapter = createRegexRedaction();
|
| 6 |
+
|
| 7 |
+
// -----------------------------------------------------------------------
|
| 8 |
+
// Aadhaar
|
| 9 |
+
// -----------------------------------------------------------------------
|
| 10 |
+
it("detects Aadhaar with valid Verhoeff checksum", () => {
|
| 11 |
+
const spans = adapter.detect("My aadhaar is 1234 5678 9010");
|
| 12 |
+
const aadhaarSpans = spans.filter((s) => s.piiType === "AADHAAR");
|
| 13 |
+
expect(aadhaarSpans.length).toBe(1);
|
| 14 |
+
expect(aadhaarSpans[0]!.replacement).toBe("[PII:AADHAAR]");
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
it("does NOT detect Aadhaar with invalid Verhoeff checksum", () => {
|
| 18 |
+
const spans = adapter.detect("My aadhaar is 1234 5678 9012");
|
| 19 |
+
const aadhaarSpans = spans.filter((s) => s.piiType === "AADHAAR");
|
| 20 |
+
expect(aadhaarSpans.length).toBe(0);
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
it("detects compact Aadhaar (no spaces)", () => {
|
| 24 |
+
const spans = adapter.detect("Aadhaar: 123456789010");
|
| 25 |
+
const aadhaarSpans = spans.filter((s) => s.piiType === "AADHAAR");
|
| 26 |
+
expect(aadhaarSpans.length).toBe(1);
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
// -----------------------------------------------------------------------
|
| 30 |
+
// PAN
|
| 31 |
+
// -----------------------------------------------------------------------
|
| 32 |
+
it("detects PAN", () => {
|
| 33 |
+
const spans = adapter.detect("PAN: ABCPE1234F");
|
| 34 |
+
const panSpans = spans.filter((s) => s.piiType === "PAN");
|
| 35 |
+
expect(panSpans.length).toBe(1);
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
it("does NOT detect PAN with invalid 4th character", () => {
|
| 39 |
+
const spans = adapter.detect("PAN: ABCZE1234F");
|
| 40 |
+
const panSpans = spans.filter((s) => s.piiType === "PAN");
|
| 41 |
+
// Z is not in [ABCFGHLJPTK] → 4th char must be one of those
|
| 42 |
+
expect(panSpans.length).toBe(0);
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
// -----------------------------------------------------------------------
|
| 46 |
+
// Phone
|
| 47 |
+
// -----------------------------------------------------------------------
|
| 48 |
+
it("detects Indian phone with +91 prefix", () => {
|
| 49 |
+
const spans = adapter.detect("Call me at +919876543210");
|
| 50 |
+
const phoneSpans = spans.filter((s) => s.piiType === "PHONE_IN");
|
| 51 |
+
expect(phoneSpans.length).toBe(1);
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
it("detects Indian phone without prefix", () => {
|
| 55 |
+
const spans = adapter.detect("Call me at 9876543210");
|
| 56 |
+
const phoneSpans = spans.filter((s) => s.piiType === "PHONE_IN");
|
| 57 |
+
expect(phoneSpans.length).toBe(1);
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
it("does NOT detect invalid phone starting with 5", () => {
|
| 61 |
+
const spans = adapter.detect("Call me at 5123456789");
|
| 62 |
+
const phoneSpans = spans.filter((s) => s.piiType === "PHONE_IN");
|
| 63 |
+
expect(phoneSpans.length).toBe(0);
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
// -----------------------------------------------------------------------
|
| 67 |
+
// Email
|
| 68 |
+
// -----------------------------------------------------------------------
|
| 69 |
+
it("detects email address", () => {
|
| 70 |
+
const spans = adapter.detect("Email: test@example.com");
|
| 71 |
+
const emailSpans = spans.filter((s) => s.piiType === "EMAIL");
|
| 72 |
+
expect(emailSpans.length).toBe(1);
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
// -----------------------------------------------------------------------
|
| 76 |
+
// UPI VPA
|
| 77 |
+
// -----------------------------------------------------------------------
|
| 78 |
+
it("detects UPI VPA", () => {
|
| 79 |
+
const spans = adapter.detect("Pay to user@upi");
|
| 80 |
+
const upiSpans = spans.filter((s) => s.piiType === "UPI_VPA");
|
| 81 |
+
expect(upiSpans.length).toBe(1);
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
// -----------------------------------------------------------------------
|
| 85 |
+
// IFSC
|
| 86 |
+
// -----------------------------------------------------------------------
|
| 87 |
+
it("detects IFSC code", () => {
|
| 88 |
+
const spans = adapter.detect("IFSC: SBIN0123456");
|
| 89 |
+
const ifscSpans = spans.filter((s) => s.piiType === "IFSC");
|
| 90 |
+
expect(ifscSpans.length).toBe(1);
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
it("does NOT detect IFSC with non-zero 6th character", () => {
|
| 94 |
+
const spans = adapter.detect("IFSC: SBIN1123456");
|
| 95 |
+
const ifscSpans = spans.filter((s) => s.piiType === "IFSC");
|
| 96 |
+
expect(ifscSpans.length).toBe(0);
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
// -----------------------------------------------------------------------
|
| 100 |
+
// Bank account
|
| 101 |
+
// -----------------------------------------------------------------------
|
| 102 |
+
it("detects bank account number", () => {
|
| 103 |
+
const spans = adapter.detect("Account: 123456789012345678");
|
| 104 |
+
const bankSpans = spans.filter((s) => s.piiType === "BANK_ACCOUNT");
|
| 105 |
+
expect(bankSpans.length).toBe(1);
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
it("does NOT detect short number as bank account", () => {
|
| 109 |
+
const spans = adapter.detect("Number: 12345678");
|
| 110 |
+
const bankSpans = spans.filter((s) => s.piiType === "BANK_ACCOUNT");
|
| 111 |
+
expect(bankSpans.length).toBe(0);
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
// -----------------------------------------------------------------------
|
| 115 |
+
// redact() with mask mode (default)
|
| 116 |
+
// -----------------------------------------------------------------------
|
| 117 |
+
it("redacts Aadhaar in text", () => {
|
| 118 |
+
const result = adapter.redact("My aadhaar is 1234 5678 9010", "mask");
|
| 119 |
+
expect(result).toContain("[PII:AADHAAR]");
|
| 120 |
+
expect(result).not.toContain("1234 5678 9010");
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
it("redacts PAN in text", () => {
|
| 124 |
+
const result = adapter.redact("PAN: ABCPE1234F", "mask");
|
| 125 |
+
expect(result).toContain("[PII:PAN]");
|
| 126 |
+
expect(result).not.toContain("ABCPE1234F");
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
it("redacts multiple PII types in one string", () => {
|
| 130 |
+
const input = "Aadhaar: 1234 5678 9010, Email: test@example.com";
|
| 131 |
+
const result = adapter.redact(input, "mask");
|
| 132 |
+
expect(result).toContain("[PII:AADHAAR]");
|
| 133 |
+
expect(result).toContain("[PII:EMAIL]");
|
| 134 |
+
expect(result).not.toContain("1234 5678 9010");
|
| 135 |
+
expect(result).not.toContain("test@example.com");
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
it("redacts with remove mode", () => {
|
| 139 |
+
const result = adapter.redact("PAN: ABCPE1234F", "remove");
|
| 140 |
+
expect(result).not.toContain("ABCPE1234F");
|
| 141 |
+
expect(result).not.toContain("[PII:PAN]");
|
| 142 |
+
});
|
| 143 |
+
|
| 144 |
+
it("redacts with tag mode", () => {
|
| 145 |
+
const result = adapter.redact("PAN: ABCPE1234F", "tag");
|
| 146 |
+
expect(result).toContain("[PAN]");
|
| 147 |
+
expect(result).not.toContain("ABCPE1234F");
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
it("returns original text when no PII found", () => {
|
| 151 |
+
const input = "Hello, this is a normal message with no PII.";
|
| 152 |
+
const result = adapter.redact(input, "mask");
|
| 153 |
+
expect(result).toBe(input);
|
| 154 |
+
});
|
| 155 |
+
});
|
packages/adapters/src/index.ts
CHANGED
|
@@ -37,6 +37,14 @@ export {
|
|
| 37 |
// ---- Storage ------------------------------------------------------------
|
| 38 |
export { createLocalFsStorage, LocalFsStorageAdapter } from "./storage/local-fs.js";
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
// ---- Capability registry ------------------------------------------------
|
| 41 |
|
| 42 |
/**
|
|
@@ -49,6 +57,8 @@ export function getCapabilities(): AdapterCapability[] {
|
|
| 49 |
const waPhoneId = process.env["WHATSAPP_PHONE_NUMBER_ID"];
|
| 50 |
const openrouterKey = process.env["OPENROUTER_API_KEY"];
|
| 51 |
const opencodeKey = process.env["OPENCODE_API_KEY"];
|
|
|
|
|
|
|
| 52 |
const embeddingProvider = process.env["EMBEDDING_PROVIDER"] ?? process.env["AI_PROVIDER"] ?? "openrouter";
|
| 53 |
const embeddingKeyAvailable = embeddingProvider === "opencode" ? !!opencodeKey : !!openrouterKey;
|
| 54 |
|
|
@@ -98,8 +108,8 @@ export function getCapabilities(): AdapterCapability[] {
|
|
| 98 |
{
|
| 99 |
adapter: "resend_email",
|
| 100 |
action: "send_email",
|
| 101 |
-
status: "
|
| 102 |
-
reason: "
|
| 103 |
requiredEnv: ["RESEND_API_KEY", "RESEND_FROM"],
|
| 104 |
fallbackAction: "draft",
|
| 105 |
},
|
|
|
|
| 37 |
// ---- Storage ------------------------------------------------------------
|
| 38 |
export { createLocalFsStorage, LocalFsStorageAdapter } from "./storage/local-fs.js";
|
| 39 |
|
| 40 |
+
// ---- Email / submission -------------------------------------------------
|
| 41 |
+
export { createResendEmailAdapter } from "./submission/resend-email.js";
|
| 42 |
+
export type { ResendEmailAdapter } from "./submission/resend-email.js";
|
| 43 |
+
|
| 44 |
+
// ---- Redaction ----------------------------------------------------------
|
| 45 |
+
export { createRegexRedaction } from "./redaction/regex.js";
|
| 46 |
+
export type { RegexRedactionAdapter } from "./redaction/regex.js";
|
| 47 |
+
|
| 48 |
// ---- Capability registry ------------------------------------------------
|
| 49 |
|
| 50 |
/**
|
|
|
|
| 57 |
const waPhoneId = process.env["WHATSAPP_PHONE_NUMBER_ID"];
|
| 58 |
const openrouterKey = process.env["OPENROUTER_API_KEY"];
|
| 59 |
const opencodeKey = process.env["OPENCODE_API_KEY"];
|
| 60 |
+
const resendKey = process.env["RESEND_API_KEY"];
|
| 61 |
+
const resendFrom = process.env["RESEND_FROM"];
|
| 62 |
const embeddingProvider = process.env["EMBEDDING_PROVIDER"] ?? process.env["AI_PROVIDER"] ?? "openrouter";
|
| 63 |
const embeddingKeyAvailable = embeddingProvider === "opencode" ? !!opencodeKey : !!openrouterKey;
|
| 64 |
|
|
|
|
| 108 |
{
|
| 109 |
adapter: "resend_email",
|
| 110 |
action: "send_email",
|
| 111 |
+
status: resendKey && resendFrom ? "available" : "blocked",
|
| 112 |
+
reason: resendKey && resendFrom ? undefined : "RESEND_API_KEY and RESEND_FROM must be set.",
|
| 113 |
requiredEnv: ["RESEND_API_KEY", "RESEND_FROM"],
|
| 114 |
fallbackAction: "draft",
|
| 115 |
},
|
packages/adapters/src/redaction/regex.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
RedactionPort,
|
| 3 |
+
RedactionSpan,
|
| 4 |
+
RedactionMode,
|
| 5 |
+
} from "@courtmitra/ports";
|
| 6 |
+
|
| 7 |
+
// ---------------------------------------------------------------------------
|
| 8 |
+
// Verhoeff check-digit validation (Aadhaar)
|
| 9 |
+
// ---------------------------------------------------------------------------
|
| 10 |
+
|
| 11 |
+
const verhoeffD: number[][] = [
|
| 12 |
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
| 13 |
+
[1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
|
| 14 |
+
[2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
|
| 15 |
+
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
|
| 16 |
+
[4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
|
| 17 |
+
[5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
|
| 18 |
+
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
|
| 19 |
+
[7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
|
| 20 |
+
[8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
|
| 21 |
+
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
|
| 22 |
+
];
|
| 23 |
+
|
| 24 |
+
const verhoeffP: number[][] = [
|
| 25 |
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
| 26 |
+
[1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
|
| 27 |
+
[5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
|
| 28 |
+
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
|
| 29 |
+
[9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
|
| 30 |
+
[4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
|
| 31 |
+
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
|
| 32 |
+
[7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
|
| 33 |
+
];
|
| 34 |
+
|
| 35 |
+
function verhoeffValidate(digits: string): boolean {
|
| 36 |
+
let c = 0;
|
| 37 |
+
const n = digits.replace(/\s/g, "").split("").map(Number);
|
| 38 |
+
for (let i = n.length - 1; i >= 0; i--) {
|
| 39 |
+
const pos = n.length - 1 - i;
|
| 40 |
+
c = verhoeffD[c]![verhoeffP[pos % 8]![n[i]!]!]!;
|
| 41 |
+
}
|
| 42 |
+
return c === 0;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// ---------------------------------------------------------------------------
|
| 46 |
+
// Regex patterns
|
| 47 |
+
// ---------------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
interface PiiPattern {
|
| 50 |
+
type: string;
|
| 51 |
+
regex: RegExp;
|
| 52 |
+
validate?: (match: string) => boolean;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const PATTERNS: PiiPattern[] = [
|
| 56 |
+
{
|
| 57 |
+
type: "AADHAAR",
|
| 58 |
+
regex: /\b\d{4}\s?\d{4}\s?\d{4}\b/g,
|
| 59 |
+
validate: (m) => verhoeffValidate(m),
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
type: "PAN",
|
| 63 |
+
regex: /\b[A-Z]{3}[ABCFGHLJPTK][A-Z]\d{4}[A-Z]\b/g,
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
type: "EMAIL",
|
| 67 |
+
regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
type: "UPI_VPA",
|
| 71 |
+
regex: /[\w.-]+@[\w.-]+/g,
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
type: "IFSC",
|
| 75 |
+
regex: /\b[A-Z]{4}0[A-Z0-9]{6}\b/g,
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
type: "PHONE_IN",
|
| 79 |
+
regex: /(?<!\d)(?:\+?91[-.\s]?)?[6-9]\d{9}\b/g,
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
type: "BANK_ACCOUNT",
|
| 83 |
+
regex: /\b\d{9,18}\b/g,
|
| 84 |
+
},
|
| 85 |
+
];
|
| 86 |
+
|
| 87 |
+
// ---------------------------------------------------------------------------
|
| 88 |
+
// Adapter
|
| 89 |
+
// ---------------------------------------------------------------------------
|
| 90 |
+
|
| 91 |
+
export class RegexRedactionAdapter implements RedactionPort {
|
| 92 |
+
detect(text: string): RedactionSpan[] {
|
| 93 |
+
const spans: RedactionSpan[] = [];
|
| 94 |
+
const seen = new Set<string>();
|
| 95 |
+
|
| 96 |
+
for (const pattern of PATTERNS) {
|
| 97 |
+
let match: RegExpExecArray | null;
|
| 98 |
+
const re = new RegExp(pattern.regex.source, "g");
|
| 99 |
+
while ((match = re.exec(text)) !== null) {
|
| 100 |
+
const value = match[0]!;
|
| 101 |
+
const key = `${match.index}:${value}`;
|
| 102 |
+
if (seen.has(key)) continue;
|
| 103 |
+
seen.add(key);
|
| 104 |
+
|
| 105 |
+
if (pattern.validate && !pattern.validate(value)) continue;
|
| 106 |
+
|
| 107 |
+
spans.push({
|
| 108 |
+
start: match.index,
|
| 109 |
+
end: match.index + value.length,
|
| 110 |
+
piiType: pattern.type,
|
| 111 |
+
replacement: `[PII:${pattern.type}]`,
|
| 112 |
+
});
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
spans.sort((a, b) => a.start - b.start);
|
| 117 |
+
return spans;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
redact(text: string, mode: RedactionMode): string {
|
| 121 |
+
const spans = this.detect(text);
|
| 122 |
+
if (spans.length === 0) return text;
|
| 123 |
+
|
| 124 |
+
const mask = (span: RedactionSpan): string => {
|
| 125 |
+
switch (mode) {
|
| 126 |
+
case "remove":
|
| 127 |
+
return "";
|
| 128 |
+
case "tag":
|
| 129 |
+
return `[${span.piiType}]`;
|
| 130 |
+
default:
|
| 131 |
+
return `[PII:${span.piiType}]`;
|
| 132 |
+
}
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
const parts: string[] = [];
|
| 136 |
+
let cursor = 0;
|
| 137 |
+
for (const span of spans) {
|
| 138 |
+
if (span.start < cursor) continue;
|
| 139 |
+
if (span.start > cursor) {
|
| 140 |
+
parts.push(text.slice(cursor, span.start));
|
| 141 |
+
}
|
| 142 |
+
parts.push(mask(span));
|
| 143 |
+
cursor = span.end;
|
| 144 |
+
}
|
| 145 |
+
if (cursor < text.length) {
|
| 146 |
+
parts.push(text.slice(cursor));
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
return parts.join("");
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
export function createRegexRedaction(): RegexRedactionAdapter {
|
| 154 |
+
return new RegexRedactionAdapter();
|
| 155 |
+
}
|
packages/adapters/src/submission/resend-email.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createHmac, randomBytes } from "node:crypto";
|
| 2 |
+
import { readFileSync, existsSync } from "node:fs";
|
| 3 |
+
import { resolve } from "node:path";
|
| 4 |
+
import { Resend } from "resend";
|
| 5 |
+
import type {
|
| 6 |
+
SubmissionPort,
|
| 7 |
+
SubmissionRequest,
|
| 8 |
+
SubmissionResult,
|
| 9 |
+
EmailPort,
|
| 10 |
+
EmailSendRequest,
|
| 11 |
+
EmailSendResult,
|
| 12 |
+
} from "@courtmitra/ports";
|
| 13 |
+
import type { AdapterCapability } from "@courtmitra/contracts";
|
| 14 |
+
|
| 15 |
+
function getEnv(name: string): string | undefined {
|
| 16 |
+
return process.env[name];
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function buildReplyToken(
|
| 20 |
+
actionId: string,
|
| 21 |
+
missionId: string,
|
| 22 |
+
nonce: string,
|
| 23 |
+
): string {
|
| 24 |
+
const key = getEnv("TOKEN_ENCRYPTION_KEY");
|
| 25 |
+
if (!key) throw new Error("Missing TOKEN_ENCRYPTION_KEY for reply token generation");
|
| 26 |
+
return createHmac("sha256", key)
|
| 27 |
+
.update(`${actionId}:${missionId}:${nonce}`)
|
| 28 |
+
.digest("hex");
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function tryReadAttachment(storageKey: string): Buffer | undefined {
|
| 32 |
+
try {
|
| 33 |
+
const storageDir = getEnv("LOCAL_STORAGE_DIR") ?? ".storage";
|
| 34 |
+
const absPath = resolve(storageDir, storageKey);
|
| 35 |
+
if (existsSync(absPath)) {
|
| 36 |
+
return readFileSync(absPath);
|
| 37 |
+
}
|
| 38 |
+
} catch {
|
| 39 |
+
/* best effort */
|
| 40 |
+
}
|
| 41 |
+
return undefined;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
type SendResult = {
|
| 45 |
+
proofState: "sent_by_email" | "failed";
|
| 46 |
+
providerMessageId: string | null;
|
| 47 |
+
proofRef: Record<string, unknown>;
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
export class ResendEmailAdapter implements SubmissionPort, EmailPort {
|
| 51 |
+
private readonly resend: Resend;
|
| 52 |
+
private readonly from: string;
|
| 53 |
+
private readonly replyDomain: string;
|
| 54 |
+
|
| 55 |
+
constructor() {
|
| 56 |
+
const apiKey = getEnv("RESEND_API_KEY");
|
| 57 |
+
const from = getEnv("RESEND_FROM");
|
| 58 |
+
const replyDomain = getEnv("RESEND_REPLY_DOMAIN");
|
| 59 |
+
this.resend = apiKey ? new Resend(apiKey) : new Resend("placeholder");
|
| 60 |
+
this.from = from ?? "";
|
| 61 |
+
this.replyDomain = replyDomain ?? "";
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
capabilities(): AdapterCapability[] {
|
| 65 |
+
const key = getEnv("RESEND_API_KEY");
|
| 66 |
+
const from = getEnv("RESEND_FROM");
|
| 67 |
+
return [
|
| 68 |
+
{
|
| 69 |
+
adapter: "resend_email",
|
| 70 |
+
action: "send_email",
|
| 71 |
+
status: key && from ? "available" : "blocked",
|
| 72 |
+
reason:
|
| 73 |
+
key && from
|
| 74 |
+
? undefined
|
| 75 |
+
: "RESEND_API_KEY and RESEND_FROM must be set.",
|
| 76 |
+
requiredEnv: ["RESEND_API_KEY", "RESEND_FROM"],
|
| 77 |
+
fallbackAction: "draft",
|
| 78 |
+
},
|
| 79 |
+
];
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
async submit(req: SubmissionRequest): Promise<SubmissionResult> {
|
| 83 |
+
const result = await this.doSend({
|
| 84 |
+
to: req.destination,
|
| 85 |
+
subject: req.subject ?? "No subject",
|
| 86 |
+
body: req.body,
|
| 87 |
+
actionId: req.actionId,
|
| 88 |
+
missionId: req.missionId,
|
| 89 |
+
attachments: req.attachments.map((a) => ({
|
| 90 |
+
filename: a.filename,
|
| 91 |
+
storageKey: a.storageKey,
|
| 92 |
+
})),
|
| 93 |
+
});
|
| 94 |
+
return {
|
| 95 |
+
proofState: result.proofState,
|
| 96 |
+
externalReference: result.providerMessageId,
|
| 97 |
+
proofRef: result.proofRef,
|
| 98 |
+
};
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
async send(req: EmailSendRequest): Promise<EmailSendResult> {
|
| 102 |
+
const result = await this.doSend({
|
| 103 |
+
to: req.to,
|
| 104 |
+
subject: req.subject,
|
| 105 |
+
body: req.body,
|
| 106 |
+
actionId: req.replyTo ?? "email",
|
| 107 |
+
missionId: req.to,
|
| 108 |
+
attachments: req.attachments ?? [],
|
| 109 |
+
});
|
| 110 |
+
return {
|
| 111 |
+
proofState: result.proofState,
|
| 112 |
+
providerMessageId: result.providerMessageId,
|
| 113 |
+
proofRef: result.proofRef,
|
| 114 |
+
};
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
private async doSend(
|
| 118 |
+
req: {
|
| 119 |
+
to: string;
|
| 120 |
+
subject: string;
|
| 121 |
+
body: string;
|
| 122 |
+
actionId: string;
|
| 123 |
+
missionId: string;
|
| 124 |
+
attachments: { filename: string; storageKey: string }[];
|
| 125 |
+
},
|
| 126 |
+
): Promise<SendResult> {
|
| 127 |
+
if (!this.from || !getEnv("RESEND_API_KEY")) {
|
| 128 |
+
return {
|
| 129 |
+
proofState: "failed",
|
| 130 |
+
providerMessageId: null,
|
| 131 |
+
proofRef: { error: "RESEND_API_KEY and RESEND_FROM must be set." },
|
| 132 |
+
};
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
const nonce = randomBytes(8).toString("hex");
|
| 136 |
+
let replyTo: string | undefined;
|
| 137 |
+
try {
|
| 138 |
+
const token = buildReplyToken(req.actionId, req.missionId, nonce);
|
| 139 |
+
if (this.replyDomain) {
|
| 140 |
+
replyTo = `reply+${token}@${this.replyDomain}`;
|
| 141 |
+
}
|
| 142 |
+
} catch {
|
| 143 |
+
/* send without reply-token */
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const resolvedAttachments = req.attachments
|
| 147 |
+
.map((a) => {
|
| 148 |
+
const content = tryReadAttachment(a.storageKey);
|
| 149 |
+
return content ? { filename: a.filename, content } : null;
|
| 150 |
+
})
|
| 151 |
+
.filter(Boolean);
|
| 152 |
+
|
| 153 |
+
try {
|
| 154 |
+
const { data, error } = await this.resend.emails.send({
|
| 155 |
+
from: this.from,
|
| 156 |
+
to: [req.to],
|
| 157 |
+
subject: req.subject,
|
| 158 |
+
html: req.body,
|
| 159 |
+
replyTo,
|
| 160 |
+
attachments:
|
| 161 |
+
resolvedAttachments.length > 0
|
| 162 |
+
? (resolvedAttachments as { filename: string; content: Buffer }[])
|
| 163 |
+
: undefined,
|
| 164 |
+
});
|
| 165 |
+
if (error) throw error;
|
| 166 |
+
return {
|
| 167 |
+
proofState: "sent_by_email",
|
| 168 |
+
providerMessageId: data?.id ?? null,
|
| 169 |
+
proofRef: { id: data?.id },
|
| 170 |
+
};
|
| 171 |
+
} catch (err) {
|
| 172 |
+
return {
|
| 173 |
+
proofState: "failed",
|
| 174 |
+
providerMessageId: null,
|
| 175 |
+
proofRef: {
|
| 176 |
+
error: err instanceof Error ? err.message : String(err),
|
| 177 |
+
},
|
| 178 |
+
};
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
export function createResendEmailAdapter(): ResendEmailAdapter {
|
| 184 |
+
return new ResendEmailAdapter();
|
| 185 |
+
}
|
packages/contracts/src/action.ts
CHANGED
|
@@ -64,3 +64,143 @@ export const ActionPreviewResponse = z.object({
|
|
| 64 |
proofStateAfter: ProofState,
|
| 65 |
});
|
| 66 |
export type ActionPreviewResponse = z.infer<typeof ActionPreviewResponse>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
proofStateAfter: ProofState,
|
| 65 |
});
|
| 66 |
export type ActionPreviewResponse = z.infer<typeof ActionPreviewResponse>;
|
| 67 |
+
|
| 68 |
+
// ---------------------------------------------------------------------------
|
| 69 |
+
// Day 4 — Action preview, consent, execution, followup, public page
|
| 70 |
+
// ---------------------------------------------------------------------------
|
| 71 |
+
|
| 72 |
+
/** Input for POST /api/missions/:id/actions/preview — selects a route-plan step and fills the template. */
|
| 73 |
+
export const CreateActionPreviewInput = z.object({
|
| 74 |
+
routePlanId: Uuid,
|
| 75 |
+
stepNo: z.number().int().min(0),
|
| 76 |
+
destination: z.string().min(1),
|
| 77 |
+
subject: z.string().optional(),
|
| 78 |
+
body: z.string().min(1),
|
| 79 |
+
attachments: z.array(ConsentPreviewAttachment).default([]),
|
| 80 |
+
piiDisclosed: z.array(z.string()).default([]),
|
| 81 |
+
risks: z.array(z.string()).default([]),
|
| 82 |
+
});
|
| 83 |
+
export type CreateActionPreviewInput = z.infer<typeof CreateActionPreviewInput>;
|
| 84 |
+
|
| 85 |
+
/** Response for the preview endpoint — includes the created action row + metadata. */
|
| 86 |
+
export const ActionPreviewDto = z.object({
|
| 87 |
+
action: Action,
|
| 88 |
+
canonicalPayloadHash: z.string(),
|
| 89 |
+
preview: ActionPreviewResponse,
|
| 90 |
+
});
|
| 91 |
+
export type ActionPreviewDto = z.infer<typeof ActionPreviewDto>;
|
| 92 |
+
|
| 93 |
+
/** Empty body for POST /api/actions/:id/consent — consent is recorded server-side. */
|
| 94 |
+
export const GrantConsentInput = z.object({}).default({});
|
| 95 |
+
export type GrantConsentInput = z.infer<typeof GrantConsentInput>;
|
| 96 |
+
|
| 97 |
+
/** Response for consent grant. */
|
| 98 |
+
export const ConsentGrantResponse = z.object({
|
| 99 |
+
id: Uuid,
|
| 100 |
+
actionId: Uuid,
|
| 101 |
+
actionPreviewHash: z.string(),
|
| 102 |
+
grantedAt: IsoDateTime,
|
| 103 |
+
});
|
| 104 |
+
export type ConsentGrantResponse = z.infer<typeof ConsentGrantResponse>;
|
| 105 |
+
|
| 106 |
+
/** Response for POST /api/actions/:id/execute. */
|
| 107 |
+
export const ExecuteActionResponse = z.object({
|
| 108 |
+
actionId: Uuid,
|
| 109 |
+
proofState: ProofState,
|
| 110 |
+
status: ActionStatus,
|
| 111 |
+
attempt: z.object({
|
| 112 |
+
id: Uuid,
|
| 113 |
+
attemptNo: z.number().int(),
|
| 114 |
+
status: z.string(),
|
| 115 |
+
}).nullable(),
|
| 116 |
+
});
|
| 117 |
+
export type ExecuteActionResponse = z.infer<typeof ExecuteActionResponse>;
|
| 118 |
+
|
| 119 |
+
/** A single action_attempts row (read model). */
|
| 120 |
+
export const ActionAttemptDto = z.object({
|
| 121 |
+
id: Uuid,
|
| 122 |
+
adapterName: z.string(),
|
| 123 |
+
attemptNo: z.number().int(),
|
| 124 |
+
status: z.string(),
|
| 125 |
+
errorCode: z.string().nullable(),
|
| 126 |
+
errorMessage: z.string().nullable(),
|
| 127 |
+
startedAt: IsoDateTime.nullable(),
|
| 128 |
+
finishedAt: IsoDateTime.nullable(),
|
| 129 |
+
});
|
| 130 |
+
export type ActionAttemptDto = z.infer<typeof ActionAttemptDto>;
|
| 131 |
+
|
| 132 |
+
/** A single submissions row (read model). */
|
| 133 |
+
export const SubmissionDto = z.object({
|
| 134 |
+
id: Uuid,
|
| 135 |
+
proofState: ProofState,
|
| 136 |
+
externalReference: z.string().nullable(),
|
| 137 |
+
sentAt: IsoDateTime.nullable(),
|
| 138 |
+
});
|
| 139 |
+
export type SubmissionDto = z.infer<typeof SubmissionDto>;
|
| 140 |
+
|
| 141 |
+
/** Full action detail returned by GET /api/actions/:id. */
|
| 142 |
+
export const ActionDetailDto = z.object({
|
| 143 |
+
action: Action,
|
| 144 |
+
latestAttempt: ActionAttemptDto.nullable(),
|
| 145 |
+
submissions: z.array(SubmissionDto).default([]),
|
| 146 |
+
});
|
| 147 |
+
export type ActionDetailDto = z.infer<typeof ActionDetailDto>;
|
| 148 |
+
|
| 149 |
+
/** Email-specific send input (for adapters). */
|
| 150 |
+
export const EmailSendRequestDto = z.object({
|
| 151 |
+
to: z.string().email(),
|
| 152 |
+
subject: z.string().min(1),
|
| 153 |
+
body: z.string().min(1),
|
| 154 |
+
replyTo: z.string().optional(),
|
| 155 |
+
attachments: z.array(z.object({
|
| 156 |
+
filename: z.string(),
|
| 157 |
+
contentBase64: z.string().optional(),
|
| 158 |
+
contentType: z.string().optional(),
|
| 159 |
+
})).default([]),
|
| 160 |
+
});
|
| 161 |
+
export type EmailSendRequestDto = z.infer<typeof EmailSendRequestDto>;
|
| 162 |
+
|
| 163 |
+
/** Email send result (adapter output). */
|
| 164 |
+
export const EmailSendResultDto = z.object({
|
| 165 |
+
proofState: ProofState,
|
| 166 |
+
providerMessageId: z.string().nullable(),
|
| 167 |
+
proofRef: z.record(z.string(), z.unknown()),
|
| 168 |
+
});
|
| 169 |
+
export type EmailSendResultDto = z.infer<typeof EmailSendResultDto>;
|
| 170 |
+
|
| 171 |
+
/** Followup row (read model). */
|
| 172 |
+
export const FollowupDto = z.object({
|
| 173 |
+
id: Uuid,
|
| 174 |
+
missionId: Uuid,
|
| 175 |
+
type: z.string(),
|
| 176 |
+
dueAt: IsoDateTime,
|
| 177 |
+
status: z.string(),
|
| 178 |
+
payload: z.record(z.string(), z.unknown()).nullable(),
|
| 179 |
+
});
|
| 180 |
+
export type FollowupDto = z.infer<typeof FollowupDto>;
|
| 181 |
+
|
| 182 |
+
/** Public case page (read model). */
|
| 183 |
+
export const PublicCasePageDto = z.object({
|
| 184 |
+
id: Uuid,
|
| 185 |
+
missionId: Uuid,
|
| 186 |
+
slug: z.string(),
|
| 187 |
+
visibility: z.string(),
|
| 188 |
+
redactedSummary: z.string().nullable(),
|
| 189 |
+
createdAt: IsoDateTime,
|
| 190 |
+
});
|
| 191 |
+
export type PublicCasePageDto = z.infer<typeof PublicCasePageDto>;
|
| 192 |
+
|
| 193 |
+
/** Error details returned when action execution is refused. */
|
| 194 |
+
export const ActionExecutionRefused = z.object({
|
| 195 |
+
refused: z.literal(true),
|
| 196 |
+
reason: z.string(),
|
| 197 |
+
code: z.enum([
|
| 198 |
+
"consent_hash_mismatch",
|
| 199 |
+
"consent_not_found",
|
| 200 |
+
"idempotency_key_used",
|
| 201 |
+
"adapter_capability_blocked",
|
| 202 |
+
"action_not_approved",
|
| 203 |
+
"action_already_executed",
|
| 204 |
+
]),
|
| 205 |
+
});
|
| 206 |
+
export type ActionExecutionRefused = z.infer<typeof ActionExecutionRefused>;
|
packages/domain/src/index.ts
CHANGED
|
@@ -4,3 +4,6 @@ export {
|
|
| 4 |
canonicalConsentJson,
|
| 5 |
canonicalizeConsentPreview,
|
| 6 |
} from "./policies/consent-hash.js";
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
canonicalConsentJson,
|
| 5 |
canonicalizeConsentPreview,
|
| 6 |
} from "./policies/consent-hash.js";
|
| 7 |
+
|
| 8 |
+
export { canExecute } from "./policies/action-execution-policy.js";
|
| 9 |
+
export type { CanExecuteResult, ExecutionRefusal, ExecutionAllowance } from "./policies/action-execution-policy.js";
|
packages/domain/src/policies/__tests__/action-execution-policy.test.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from "vitest";
|
| 2 |
+
import type { Action, ConsentPreview, AdapterCapability } from "@courtmitra/contracts";
|
| 3 |
+
import { computeConsentHash } from "../../policies/consent-hash.js";
|
| 4 |
+
import { canExecute } from "../../policies/action-execution-policy.js";
|
| 5 |
+
|
| 6 |
+
function makeAction(overrides: Partial<Action> = {}): Action {
|
| 7 |
+
return {
|
| 8 |
+
id: "00000000-0000-0000-0000-000000000001",
|
| 9 |
+
missionId: "00000000-0000-0000-0000-000000000002",
|
| 10 |
+
routePlanId: null,
|
| 11 |
+
actionType: "send_email",
|
| 12 |
+
title: "Test action",
|
| 13 |
+
description: null,
|
| 14 |
+
target: null,
|
| 15 |
+
inputPayload: null,
|
| 16 |
+
canonicalPayloadHash: null,
|
| 17 |
+
consentRequired: true,
|
| 18 |
+
consentGrantId: "00000000-0000-0000-0000-000000000010",
|
| 19 |
+
status: "approved",
|
| 20 |
+
proofState: "draft_only",
|
| 21 |
+
proofRef: null,
|
| 22 |
+
idempotencyKey: null,
|
| 23 |
+
riskLevel: "low",
|
| 24 |
+
createdBy: "ai",
|
| 25 |
+
createdAt: "2025-01-01T00:00:00+00:00",
|
| 26 |
+
updatedAt: "2025-01-01T00:00:00+00:00",
|
| 27 |
+
...overrides,
|
| 28 |
+
};
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function makeConsentPreview(overrides: Partial<ConsentPreview> = {}): ConsentPreview {
|
| 32 |
+
return {
|
| 33 |
+
actionType: "send_email",
|
| 34 |
+
destination: "test@example.com",
|
| 35 |
+
public: false,
|
| 36 |
+
body: "Test body content",
|
| 37 |
+
attachments: [],
|
| 38 |
+
piiDisclosed: [],
|
| 39 |
+
risks: [],
|
| 40 |
+
...overrides,
|
| 41 |
+
};
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
describe("canExecute", () => {
|
| 45 |
+
it("refuses when consent is null", () => {
|
| 46 |
+
const action = makeAction();
|
| 47 |
+
const preview = makeConsentPreview();
|
| 48 |
+
const caps: AdapterCapability[] = [];
|
| 49 |
+
|
| 50 |
+
const result = canExecute(action, null, preview, caps);
|
| 51 |
+
|
| 52 |
+
expect(result.refused).toBe(true);
|
| 53 |
+
if (result.refused) {
|
| 54 |
+
expect(result.code).toBe("consent_not_found");
|
| 55 |
+
}
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
it("refuses when consent hash does not match", () => {
|
| 59 |
+
const action = makeAction();
|
| 60 |
+
const consentGrant = { actionPreviewHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" };
|
| 61 |
+
const preview = makeConsentPreview({ body: "Completely different body text" });
|
| 62 |
+
const caps: AdapterCapability[] = [];
|
| 63 |
+
|
| 64 |
+
const result = canExecute(action, consentGrant, preview, caps);
|
| 65 |
+
|
| 66 |
+
expect(result.refused).toBe(true);
|
| 67 |
+
if (result.refused) {
|
| 68 |
+
expect(result.code).toBe("consent_hash_mismatch");
|
| 69 |
+
}
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
it("refuses when action status is not approved", () => {
|
| 73 |
+
const action = makeAction({ status: "proposed" });
|
| 74 |
+
const preview = makeConsentPreview();
|
| 75 |
+
const hash = computeConsentHash(preview);
|
| 76 |
+
const consentGrant = { actionPreviewHash: hash };
|
| 77 |
+
const caps: AdapterCapability[] = [];
|
| 78 |
+
|
| 79 |
+
const result = canExecute(action, consentGrant, preview, caps);
|
| 80 |
+
|
| 81 |
+
expect(result.refused).toBe(true);
|
| 82 |
+
if (result.refused) {
|
| 83 |
+
expect(result.code).toBe("action_not_approved");
|
| 84 |
+
}
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
it("refuses when adapter capability is blocked", () => {
|
| 88 |
+
const action = makeAction({ actionType: "send_email" });
|
| 89 |
+
const preview = makeConsentPreview();
|
| 90 |
+
const hash = computeConsentHash(preview);
|
| 91 |
+
const consentGrant = { actionPreviewHash: hash };
|
| 92 |
+
const caps: AdapterCapability[] = [
|
| 93 |
+
{
|
| 94 |
+
adapter: "resend_email",
|
| 95 |
+
action: "send_email",
|
| 96 |
+
status: "blocked",
|
| 97 |
+
reason: "API key not set.",
|
| 98 |
+
fallbackAction: "draft",
|
| 99 |
+
},
|
| 100 |
+
];
|
| 101 |
+
|
| 102 |
+
const result = canExecute(action, consentGrant, preview, caps);
|
| 103 |
+
|
| 104 |
+
expect(result.refused).toBe(true);
|
| 105 |
+
if (result.refused) {
|
| 106 |
+
expect(result.code).toBe("adapter_capability_blocked");
|
| 107 |
+
}
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
it("allows execution when all conditions pass", () => {
|
| 111 |
+
const action = makeAction({ actionType: "send_email" });
|
| 112 |
+
const preview = makeConsentPreview();
|
| 113 |
+
const hash = computeConsentHash(preview);
|
| 114 |
+
const consentGrant = { actionPreviewHash: hash };
|
| 115 |
+
const caps: AdapterCapability[] = [
|
| 116 |
+
{
|
| 117 |
+
adapter: "resend_email",
|
| 118 |
+
action: "send_email",
|
| 119 |
+
status: "available",
|
| 120 |
+
fallbackAction: "draft",
|
| 121 |
+
},
|
| 122 |
+
];
|
| 123 |
+
|
| 124 |
+
const result = canExecute(action, consentGrant, preview, caps);
|
| 125 |
+
|
| 126 |
+
expect(result.refused).toBe(false);
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
it("allows execution with dev_only capability", () => {
|
| 130 |
+
const action = makeAction({ actionType: "send_email" });
|
| 131 |
+
const preview = makeConsentPreview();
|
| 132 |
+
const hash = computeConsentHash(preview);
|
| 133 |
+
const consentGrant = { actionPreviewHash: hash };
|
| 134 |
+
const caps: AdapterCapability[] = [
|
| 135 |
+
{
|
| 136 |
+
adapter: "resend_email",
|
| 137 |
+
action: "send_email",
|
| 138 |
+
status: "dev_only",
|
| 139 |
+
fallbackAction: "draft",
|
| 140 |
+
},
|
| 141 |
+
];
|
| 142 |
+
|
| 143 |
+
const result = canExecute(action, consentGrant, preview, caps);
|
| 144 |
+
|
| 145 |
+
expect(result.refused).toBe(false);
|
| 146 |
+
});
|
| 147 |
+
});
|
packages/domain/src/policies/action-execution-policy.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ActionExecutionPolicy — pure rules for `canExecute`.
|
| 3 |
+
*
|
| 4 |
+
* Order: consent → idempotency → capability → rate-limit.
|
| 5 |
+
* Failure of any step returns `ActionExecutionRefused` with a typed reason.
|
| 6 |
+
* This is the invariant core of the Action spine (CLAUDE.md #2, #3, #5, #7).
|
| 7 |
+
*/
|
| 8 |
+
import type {
|
| 9 |
+
Action,
|
| 10 |
+
AdapterCapability,
|
| 11 |
+
ActionExecutionRefused,
|
| 12 |
+
} from "@courtmitra/contracts";
|
| 13 |
+
import { consentHashMatches } from "./consent-hash.js";
|
| 14 |
+
|
| 15 |
+
// ---------------------------------------------------------------------------
|
| 16 |
+
// Refusal result
|
| 17 |
+
// ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
export interface ExecutionRefusal {
|
| 20 |
+
refused: true;
|
| 21 |
+
reason: string;
|
| 22 |
+
code: ActionExecutionRefused["code"];
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export interface ExecutionAllowance {
|
| 26 |
+
refused: false;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export type CanExecuteResult = ExecutionRefusal | ExecutionAllowance;
|
| 30 |
+
|
| 31 |
+
// ---------------------------------------------------------------------------
|
| 32 |
+
// Adapter capability check
|
| 33 |
+
// ---------------------------------------------------------------------------
|
| 34 |
+
|
| 35 |
+
function adapterForAction(action: Action): string | null {
|
| 36 |
+
switch (action.actionType) {
|
| 37 |
+
case "send_email":
|
| 38 |
+
return "resend_email";
|
| 39 |
+
case "post_social":
|
| 40 |
+
return "social_publisher";
|
| 41 |
+
case "generate_packet":
|
| 42 |
+
return "packet_generator";
|
| 43 |
+
default:
|
| 44 |
+
return null;
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function capabilityIsAvailable(cap: AdapterCapability): boolean {
|
| 49 |
+
return cap.status === "available" || cap.status === "dev_only";
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// ---------------------------------------------------------------------------
|
| 53 |
+
// Public gate — pure function, zero side-effects
|
| 54 |
+
// ---------------------------------------------------------------------------
|
| 55 |
+
|
| 56 |
+
/**
|
| 57 |
+
* Evaluate whether an action may be executed.
|
| 58 |
+
*
|
| 59 |
+
* @param action — the Action row to execute.
|
| 60 |
+
* @param consentGrant — the consent grant (null if not yet granted).
|
| 61 |
+
* @param outgoingPreview — the ConsentPreview computed from the EXACT outgoing payload.
|
| 62 |
+
* @param allCapabilities — full capability registry (adapter list).
|
| 63 |
+
* @returns `{ refused: false }` or `{ refused: true, reason, code }`.
|
| 64 |
+
*/
|
| 65 |
+
export function canExecute(
|
| 66 |
+
action: Action,
|
| 67 |
+
consentGrant: { actionPreviewHash: string } | null,
|
| 68 |
+
outgoingPreview: Parameters<typeof consentHashMatches>[1],
|
| 69 |
+
allCapabilities: AdapterCapability[],
|
| 70 |
+
): CanExecuteResult {
|
| 71 |
+
// 1. Consent must exist
|
| 72 |
+
if (!consentGrant) {
|
| 73 |
+
return {
|
| 74 |
+
refused: true,
|
| 75 |
+
reason: "Consent has not been granted for this action.",
|
| 76 |
+
code: "consent_not_found",
|
| 77 |
+
};
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// 2. Consent hash must match the outgoing payload (CLAUDE.md #5)
|
| 81 |
+
if (!consentHashMatches(consentGrant.actionPreviewHash, outgoingPreview)) {
|
| 82 |
+
return {
|
| 83 |
+
refused: true,
|
| 84 |
+
reason:
|
| 85 |
+
"The action draft has changed since consent was given — re-approval required.",
|
| 86 |
+
code: "consent_hash_mismatch",
|
| 87 |
+
};
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// 3. Action must be in an executable status
|
| 91 |
+
// "executing" is allowed for retries — the worker sets it inside its tx.
|
| 92 |
+
const executableStatuses = new Set(["approved", "preview_ready", "executing"]);
|
| 93 |
+
if (!executableStatuses.has(action.status)) {
|
| 94 |
+
return {
|
| 95 |
+
refused: true,
|
| 96 |
+
reason: `Action status is "${action.status}", not "approved", "preview_ready", or "executing".`,
|
| 97 |
+
code: "action_not_approved",
|
| 98 |
+
};
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
// 4. Idempotency key must not already have been used (caller checks DB)
|
| 102 |
+
if (action.idempotencyKey && action.proofState === "sent_by_email") {
|
| 103 |
+
return {
|
| 104 |
+
refused: true,
|
| 105 |
+
reason: "This action has already been executed (idempotency key consumed).",
|
| 106 |
+
code: "idempotency_key_used",
|
| 107 |
+
};
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
// 5. Adapter capability must be available
|
| 111 |
+
const adapterName = adapterForAction(action);
|
| 112 |
+
if (adapterName) {
|
| 113 |
+
const matching = allCapabilities.find((c) => c.adapter === adapterName);
|
| 114 |
+
if (!matching || !capabilityIsAvailable(matching)) {
|
| 115 |
+
return {
|
| 116 |
+
refused: true,
|
| 117 |
+
reason: matching
|
| 118 |
+
? `Adapter "${adapterName}" is not available (status: ${matching.status}). ${matching.reason ?? ""}`
|
| 119 |
+
: `No adapter found for "${adapterName}".`,
|
| 120 |
+
code: "adapter_capability_blocked",
|
| 121 |
+
};
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
return { refused: false };
|
| 126 |
+
}
|
packages/ports/src/index.ts
CHANGED
|
@@ -157,6 +157,27 @@ export interface SubmissionPort extends CapabilityAware {
|
|
| 157 |
submit(r: SubmissionRequest): Promise<SubmissionResult>;
|
| 158 |
}
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
// --- Redaction ---------------------------------------------------------------
|
| 161 |
|
| 162 |
export type RedactionMode = "mask" | "remove" | "tag";
|
|
|
|
| 157 |
submit(r: SubmissionRequest): Promise<SubmissionResult>;
|
| 158 |
}
|
| 159 |
|
| 160 |
+
// --- Email (dedicated port, separate from generic SubmissionPort) -----------
|
| 161 |
+
|
| 162 |
+
export interface EmailSendRequest {
|
| 163 |
+
to: string;
|
| 164 |
+
subject: string;
|
| 165 |
+
body: string;
|
| 166 |
+
replyTo?: string;
|
| 167 |
+
/** Attachments as base64-encoded content (referenced by storage key). */
|
| 168 |
+
attachments?: { filename: string; storageKey: string; contentType?: string }[];
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
export interface EmailSendResult {
|
| 172 |
+
proofState: ProofState;
|
| 173 |
+
providerMessageId: string | null;
|
| 174 |
+
proofRef: Record<string, unknown>;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
export interface EmailPort extends CapabilityAware {
|
| 178 |
+
send(req: EmailSendRequest): Promise<EmailSendResult>;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
// --- Redaction ---------------------------------------------------------------
|
| 182 |
|
| 183 |
export type RedactionMode = "mask" | "remove" | "tag";
|
pnpm-lock.yaml
CHANGED
|
@@ -176,6 +176,9 @@ importers:
|
|
| 176 |
drizzle-orm:
|
| 177 |
specifier: ^0.45.2
|
| 178 |
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)
|
|
|
|
|
|
|
|
|
|
| 179 |
zod:
|
| 180 |
specifier: ^4.4.3
|
| 181 |
version: 4.4.3
|
|
@@ -1695,6 +1698,9 @@ packages:
|
|
| 1695 |
'@rolldown/pluginutils@1.0.1':
|
| 1696 |
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
| 1697 |
|
|
|
|
|
|
|
|
|
|
| 1698 |
'@standard-schema/spec@1.1.0':
|
| 1699 |
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
| 1700 |
|
|
@@ -2280,6 +2286,9 @@ packages:
|
|
| 2280 |
fast-safe-stringify@2.1.1:
|
| 2281 |
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
| 2282 |
|
|
|
|
|
|
|
|
|
|
| 2283 |
fast-uri@3.1.2:
|
| 2284 |
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
| 2285 |
|
|
@@ -2763,6 +2772,9 @@ packages:
|
|
| 2763 |
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
| 2764 |
hasBin: true
|
| 2765 |
|
|
|
|
|
|
|
|
|
|
| 2766 |
postcss@8.4.31:
|
| 2767 |
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
| 2768 |
engines: {node: ^10 || ^12 || >=14}
|
|
@@ -2843,6 +2855,15 @@ packages:
|
|
| 2843 |
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
|
| 2844 |
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
|
| 2845 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2846 |
resolve-from@4.0.0:
|
| 2847 |
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
| 2848 |
engines: {node: '>=4'}
|
|
@@ -2936,6 +2957,9 @@ packages:
|
|
| 2936 |
stackback@0.0.2:
|
| 2937 |
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
| 2938 |
|
|
|
|
|
|
|
|
|
|
| 2939 |
std-env@4.1.0:
|
| 2940 |
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
| 2941 |
|
|
@@ -4548,6 +4572,8 @@ snapshots:
|
|
| 4548 |
|
| 4549 |
'@rolldown/pluginutils@1.0.1': {}
|
| 4550 |
|
|
|
|
|
|
|
| 4551 |
'@standard-schema/spec@1.1.0': {}
|
| 4552 |
|
| 4553 |
'@swc/helpers@0.5.15':
|
|
@@ -5152,6 +5178,8 @@ snapshots:
|
|
| 5152 |
|
| 5153 |
fast-safe-stringify@2.1.1: {}
|
| 5154 |
|
|
|
|
|
|
|
| 5155 |
fast-uri@3.1.2: {}
|
| 5156 |
|
| 5157 |
fastify-plugin@5.1.0: {}
|
|
@@ -5612,6 +5640,8 @@ snapshots:
|
|
| 5612 |
sonic-boom: 4.2.1
|
| 5613 |
thread-stream: 4.2.0
|
| 5614 |
|
|
|
|
|
|
|
| 5615 |
postcss@8.4.31:
|
| 5616 |
dependencies:
|
| 5617 |
nanoid: 3.3.12
|
|
@@ -5691,6 +5721,11 @@ snapshots:
|
|
| 5691 |
transitivePeerDependencies:
|
| 5692 |
- supports-color
|
| 5693 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5694 |
resolve-from@4.0.0: {}
|
| 5695 |
|
| 5696 |
resolve-pkg-maps@1.0.0: {}
|
|
@@ -5806,6 +5841,11 @@ snapshots:
|
|
| 5806 |
|
| 5807 |
stackback@0.0.2: {}
|
| 5808 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5809 |
std-env@4.1.0: {}
|
| 5810 |
|
| 5811 |
string-width@4.2.3:
|
|
|
|
| 176 |
drizzle-orm:
|
| 177 |
specifier: ^0.45.2
|
| 178 |
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)
|
| 179 |
+
resend:
|
| 180 |
+
specifier: ^6.12.4
|
| 181 |
+
version: 6.12.4
|
| 182 |
zod:
|
| 183 |
specifier: ^4.4.3
|
| 184 |
version: 4.4.3
|
|
|
|
| 1698 |
'@rolldown/pluginutils@1.0.1':
|
| 1699 |
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
| 1700 |
|
| 1701 |
+
'@stablelib/base64@1.0.1':
|
| 1702 |
+
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
| 1703 |
+
|
| 1704 |
'@standard-schema/spec@1.1.0':
|
| 1705 |
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
| 1706 |
|
|
|
|
| 2286 |
fast-safe-stringify@2.1.1:
|
| 2287 |
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
| 2288 |
|
| 2289 |
+
fast-sha256@1.3.0:
|
| 2290 |
+
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
|
| 2291 |
+
|
| 2292 |
fast-uri@3.1.2:
|
| 2293 |
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
| 2294 |
|
|
|
|
| 2772 |
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
| 2773 |
hasBin: true
|
| 2774 |
|
| 2775 |
+
postal-mime@2.7.4:
|
| 2776 |
+
resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==}
|
| 2777 |
+
|
| 2778 |
postcss@8.4.31:
|
| 2779 |
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
| 2780 |
engines: {node: ^10 || ^12 || >=14}
|
|
|
|
| 2855 |
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
|
| 2856 |
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
|
| 2857 |
|
| 2858 |
+
resend@6.12.4:
|
| 2859 |
+
resolution: {integrity: sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg==}
|
| 2860 |
+
engines: {node: '>=20'}
|
| 2861 |
+
peerDependencies:
|
| 2862 |
+
'@react-email/render': '*'
|
| 2863 |
+
peerDependenciesMeta:
|
| 2864 |
+
'@react-email/render':
|
| 2865 |
+
optional: true
|
| 2866 |
+
|
| 2867 |
resolve-from@4.0.0:
|
| 2868 |
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
| 2869 |
engines: {node: '>=4'}
|
|
|
|
| 2957 |
stackback@0.0.2:
|
| 2958 |
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
| 2959 |
|
| 2960 |
+
standardwebhooks@1.0.0:
|
| 2961 |
+
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
|
| 2962 |
+
|
| 2963 |
std-env@4.1.0:
|
| 2964 |
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
| 2965 |
|
|
|
|
| 4572 |
|
| 4573 |
'@rolldown/pluginutils@1.0.1': {}
|
| 4574 |
|
| 4575 |
+
'@stablelib/base64@1.0.1': {}
|
| 4576 |
+
|
| 4577 |
'@standard-schema/spec@1.1.0': {}
|
| 4578 |
|
| 4579 |
'@swc/helpers@0.5.15':
|
|
|
|
| 5178 |
|
| 5179 |
fast-safe-stringify@2.1.1: {}
|
| 5180 |
|
| 5181 |
+
fast-sha256@1.3.0: {}
|
| 5182 |
+
|
| 5183 |
fast-uri@3.1.2: {}
|
| 5184 |
|
| 5185 |
fastify-plugin@5.1.0: {}
|
|
|
|
| 5640 |
sonic-boom: 4.2.1
|
| 5641 |
thread-stream: 4.2.0
|
| 5642 |
|
| 5643 |
+
postal-mime@2.7.4: {}
|
| 5644 |
+
|
| 5645 |
postcss@8.4.31:
|
| 5646 |
dependencies:
|
| 5647 |
nanoid: 3.3.12
|
|
|
|
| 5721 |
transitivePeerDependencies:
|
| 5722 |
- supports-color
|
| 5723 |
|
| 5724 |
+
resend@6.12.4:
|
| 5725 |
+
dependencies:
|
| 5726 |
+
postal-mime: 2.7.4
|
| 5727 |
+
standardwebhooks: 1.0.0
|
| 5728 |
+
|
| 5729 |
resolve-from@4.0.0: {}
|
| 5730 |
|
| 5731 |
resolve-pkg-maps@1.0.0: {}
|
|
|
|
| 5841 |
|
| 5842 |
stackback@0.0.2: {}
|
| 5843 |
|
| 5844 |
+
standardwebhooks@1.0.0:
|
| 5845 |
+
dependencies:
|
| 5846 |
+
'@stablelib/base64': 1.0.1
|
| 5847 |
+
fast-sha256: 1.3.0
|
| 5848 |
+
|
| 5849 |
std-env@4.1.0: {}
|
| 5850 |
|
| 5851 |
string-width@4.2.3:
|