Files
fusion/packages/core/src/approval-request-store.ts
Fusion (runfusion.ai) b8b97655b2 feat(FN-4853): complete Step 2 — aggregate pending approval counts
Fusion-Task-Id: FN-4853
Fusion-Task-Lineage: aaad0ff7-714c-4c82-a1e7-c9a725db90cb
2026-05-17 01:01:52 -07:00

324 lines
9.9 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type { Database } from "./db.js";
import { fromJson, toJsonNullable } from "./db.js";
import {
isValidApprovalRequestTransition,
normalizeApprovalRequestActionCategory,
type ApprovalRequest,
type ApprovalRequestActorSnapshot,
type ApprovalRequestAuditEvent,
type ApprovalRequestAuditEventType,
type ApprovalRequestCompletionInput,
type ApprovalRequestCreateInput,
type ApprovalRequestDecisionInput,
type ApprovalRequestListInput,
type ApprovalRequestStatus,
} from "./types.js";
interface ApprovalRequestRow {
id: string;
status: ApprovalRequestStatus;
requesterActorId: string;
requesterActorType: ApprovalRequestActorSnapshot["actorType"];
requesterActorName: string;
targetActionCategory: string;
targetActionOperation: string;
targetActionSummary: string;
targetResourceType: string;
targetResourceId: string;
targetContext: string | null;
taskId: string | null;
runId: string | null;
requestedAt: string;
decidedAt: string | null;
completedAt: string | null;
createdAt: string;
updatedAt: string;
}
interface ApprovalRequestAuditEventRow {
id: string;
requestId: string;
eventType: ApprovalRequestAuditEventType;
actorId: string;
actorType: ApprovalRequestActorSnapshot["actorType"];
actorName: string;
note: string | null;
createdAt: string;
}
export class ApprovalRequestStore {
constructor(private db: Database) {}
private rowToRequest(row: ApprovalRequestRow): ApprovalRequest {
return {
id: row.id,
status: row.status,
requester: {
actorId: row.requesterActorId,
actorType: row.requesterActorType,
actorName: row.requesterActorName,
},
targetAction: {
category: normalizeApprovalRequestActionCategory(
row.targetActionCategory as Parameters<typeof normalizeApprovalRequestActionCategory>[0],
),
action: row.targetActionOperation,
summary: row.targetActionSummary,
resourceType: row.targetResourceType,
resourceId: row.targetResourceId,
context: fromJson<Record<string, unknown>>(row.targetContext),
},
taskId: row.taskId ?? undefined,
runId: row.runId ?? undefined,
requestedAt: row.requestedAt,
decidedAt: row.decidedAt ?? undefined,
completedAt: row.completedAt ?? undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private rowToAuditEvent(row: ApprovalRequestAuditEventRow): ApprovalRequestAuditEvent {
return {
id: row.id,
requestId: row.requestId,
eventType: row.eventType,
actor: {
actorId: row.actorId,
actorType: row.actorType,
actorName: row.actorName,
},
note: row.note ?? undefined,
createdAt: row.createdAt,
};
}
private appendAuditEvent(
requestId: string,
eventType: ApprovalRequestAuditEventType,
actor: ApprovalRequestActorSnapshot,
createdAt: string,
note?: string,
): ApprovalRequestAuditEvent {
const event: ApprovalRequestAuditEvent = {
id: `aprevt-${randomUUID().slice(0, 8)}`,
requestId,
eventType,
actor,
...(note !== undefined ? { note } : {}),
createdAt,
};
this.db.prepare(`
INSERT INTO approval_request_audit_events (id, requestId, eventType, actorId, actorType, actorName, note, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
event.id,
event.requestId,
event.eventType,
event.actor.actorId,
event.actor.actorType,
event.actor.actorName,
event.note ?? null,
event.createdAt,
);
return event;
}
create(input: ApprovalRequestCreateInput): ApprovalRequest {
const now = new Date().toISOString();
const request: ApprovalRequest = {
id: `apr-${randomUUID().slice(0, 8)}`,
status: "pending",
requester: input.requester,
targetAction: {
...input.targetAction,
category: normalizeApprovalRequestActionCategory(input.targetAction.category),
},
taskId: input.taskId,
runId: input.runId,
requestedAt: now,
createdAt: now,
updatedAt: now,
};
this.db.transaction(() => {
this.db.prepare(`
INSERT INTO approval_requests (
id, status,
requesterActorId, requesterActorType, requesterActorName,
targetActionCategory, targetActionOperation, targetActionSummary,
targetResourceType, targetResourceId, targetContext,
taskId, runId,
requestedAt, decidedAt, completedAt,
createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
request.id,
request.status,
request.requester.actorId,
request.requester.actorType,
request.requester.actorName,
request.targetAction.category,
request.targetAction.action,
request.targetAction.summary,
request.targetAction.resourceType,
request.targetAction.resourceId,
toJsonNullable(request.targetAction.context),
request.taskId ?? null,
request.runId ?? null,
request.requestedAt,
null,
null,
request.createdAt,
request.updatedAt,
);
this.appendAuditEvent(request.id, "created", input.requester, now);
});
this.db.bumpLastModified();
return request;
}
get(id: string): ApprovalRequest | null {
const row = this.db.prepare(`SELECT * FROM approval_requests WHERE id = ?`).get(id) as ApprovalRequestRow | undefined;
return row ? this.rowToRequest(row) : null;
}
list(input: ApprovalRequestListInput = {}): ApprovalRequest[] {
const where: string[] = [];
const params: Array<string | number> = [];
if (input.status) {
where.push("status = ?");
params.push(input.status);
}
if (input.requesterActorId) {
where.push("requesterActorId = ?");
params.push(input.requesterActorId);
}
if (input.taskId) {
where.push("taskId = ?");
params.push(input.taskId);
}
if (input.runId) {
where.push("runId = ?");
params.push(input.runId);
}
const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
const limit = input.limit ?? 100;
const offset = input.offset ?? 0;
const rows = this.db.prepare(`
SELECT * FROM approval_requests
${whereSql}
ORDER BY createdAt DESC, id DESC
LIMIT ? OFFSET ?
`).all(...params, limit, offset) as ApprovalRequestRow[];
return rows.map((row) => this.rowToRequest(row));
}
getPendingCountsByActor(): Map<string, number> {
const rows = this.db.prepare(`
SELECT requesterActorId AS actorId, COUNT(*) AS requestCount
FROM approval_requests
WHERE status = 'pending'
GROUP BY requesterActorId
`).all() as Array<{ actorId: string; requestCount: number }>;
return new Map(rows.map((row) => [row.actorId, Number(row.requestCount)]));
}
findLatestByDedupeKey(input: { requesterActorId: string; taskId?: string; dedupeKey: string }): ApprovalRequest | null {
const where = ["requesterActorId = ?"];
const params: Array<string> = [input.requesterActorId];
if (input.taskId !== undefined) {
where.push("taskId = ?");
params.push(input.taskId);
}
const rows = this.db.prepare(`
SELECT * FROM approval_requests
WHERE ${where.join(" AND ")}
ORDER BY createdAt DESC, id DESC
`).all(...params) as ApprovalRequestRow[];
for (const row of rows) {
const context = fromJson<Record<string, unknown>>(row.targetContext);
if (context?.approvalDedupeKey === input.dedupeKey) {
return this.rowToRequest(row);
}
}
return null;
}
decide(requestId: string, status: "approved" | "denied", input: ApprovalRequestDecisionInput): ApprovalRequest {
const existing = this.get(requestId);
if (!existing) {
throw new Error(`Approval request ${requestId} not found`);
}
if (!isValidApprovalRequestTransition(existing.status, status)) {
throw new Error(`Invalid approval request transition: ${existing.status} -> ${status}`);
}
const now = new Date().toISOString();
this.db.transaction(() => {
this.db.prepare(`
UPDATE approval_requests
SET status = ?, decidedAt = ?, updatedAt = ?
WHERE id = ?
`).run(status, now, now, requestId);
this.appendAuditEvent(requestId, status, input.actor, now, input.note);
});
this.db.bumpLastModified();
const updated = this.get(requestId);
if (!updated) {
throw new Error(`Approval request ${requestId} not found after update`);
}
return updated;
}
markCompleted(requestId: string, input: ApprovalRequestCompletionInput): ApprovalRequest {
const existing = this.get(requestId);
if (!existing) {
throw new Error(`Approval request ${requestId} not found`);
}
if (!isValidApprovalRequestTransition(existing.status, "completed")) {
throw new Error(`Invalid approval request transition: ${existing.status} -> completed`);
}
const now = new Date().toISOString();
this.db.transaction(() => {
this.db.prepare(`
UPDATE approval_requests
SET status = 'completed', completedAt = ?, updatedAt = ?
WHERE id = ?
`).run(now, now, requestId);
this.appendAuditEvent(requestId, "completed", input.actor, now, input.note);
});
this.db.bumpLastModified();
const updated = this.get(requestId);
if (!updated) {
throw new Error(`Approval request ${requestId} not found after completion`);
}
return updated;
}
getAuditHistory(requestId: string): ApprovalRequestAuditEvent[] {
const rows = this.db.prepare(`
SELECT * FROM approval_request_audit_events
WHERE requestId = ?
ORDER BY createdAt ASC, rowid ASC
`).all(requestId) as ApprovalRequestAuditEventRow[];
return rows.map((row) => this.rowToAuditEvent(row));
}
}