import { and, asc, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { approvalComments, approvals } from "@paperclipai/db"; import { notFound, unprocessable } from "../errors.js"; import { agentService } from "./agents.js"; import { notifyHireApproved } from "./hire-hook.js"; export function approvalService(db: Db) { const agentsSvc = agentService(db); const canResolveStatuses = new Set(["pending", "revision_requested"]); async function getExistingApproval(id: string) { const existing = await db .select() .from(approvals) .where(eq(approvals.id, id)) .then((rows) => rows[0] ?? null); if (!existing) throw notFound("Approval not found"); return existing; } return { list: (companyId: string, status?: string) => { const conditions = [eq(approvals.companyId, companyId)]; if (status) conditions.push(eq(approvals.status, status)); return db.select().from(approvals).where(and(...conditions)); }, getById: (id: string) => db .select() .from(approvals) .where(eq(approvals.id, id)) .then((rows) => rows[0] ?? null), create: (companyId: string, data: Omit) => db .insert(approvals) .values({ ...data, companyId }) .returning() .then((rows) => rows[0]), approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => { const existing = await getExistingApproval(id); if (!canResolveStatuses.has(existing.status)) { throw unprocessable("Only pending or revision requested approvals can be approved"); } const now = new Date(); const updated = await db .update(approvals) .set({ status: "approved", decidedByUserId, decisionNote: decisionNote ?? null, decidedAt: now, updatedAt: now, }) .where(eq(approvals.id, id)) .returning() .then((rows) => rows[0]); let hireApprovedAgentId: string | null = null; if (updated.type === "hire_agent") { const payload = updated.payload as Record; const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null; if (payloadAgentId) { await agentsSvc.activatePendingApproval(payloadAgentId); hireApprovedAgentId = payloadAgentId; } else { const created = await agentsSvc.create(updated.companyId, { name: String(payload.name ?? "New Agent"), role: String(payload.role ?? "general"), title: typeof payload.title === "string" ? payload.title : null, reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null, capabilities: typeof payload.capabilities === "string" ? payload.capabilities : null, adapterType: String(payload.adapterType ?? "process"), adapterConfig: typeof payload.adapterConfig === "object" && payload.adapterConfig !== null ? (payload.adapterConfig as Record) : {}, budgetMonthlyCents: typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0, metadata: typeof payload.metadata === "object" && payload.metadata !== null ? (payload.metadata as Record) : null, status: "idle", spentMonthlyCents: 0, permissions: undefined, lastHeartbeatAt: null, }); hireApprovedAgentId = created?.id ?? null; } if (hireApprovedAgentId) { void notifyHireApproved(db, { companyId: updated.companyId, agentId: hireApprovedAgentId, source: "approval", sourceId: id, approvedAt: now, }).catch(() => {}); } } return updated; }, reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => { const existing = await getExistingApproval(id); if (!canResolveStatuses.has(existing.status)) { throw unprocessable("Only pending or revision requested approvals can be rejected"); } const now = new Date(); const updated = await db .update(approvals) .set({ status: "rejected", decidedByUserId, decisionNote: decisionNote ?? null, decidedAt: now, updatedAt: now, }) .where(eq(approvals.id, id)) .returning() .then((rows) => rows[0]); if (updated.type === "hire_agent") { const payload = updated.payload as Record; const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null; if (payloadAgentId) { await agentsSvc.terminate(payloadAgentId); } } return updated; }, requestRevision: async (id: string, decidedByUserId: string, decisionNote?: string | null) => { const existing = await getExistingApproval(id); if (existing.status !== "pending") { throw unprocessable("Only pending approvals can request revision"); } const now = new Date(); return db .update(approvals) .set({ status: "revision_requested", decidedByUserId, decisionNote: decisionNote ?? null, decidedAt: now, updatedAt: now, }) .where(eq(approvals.id, id)) .returning() .then((rows) => rows[0]); }, resubmit: async (id: string, payload?: Record) => { const existing = await getExistingApproval(id); if (existing.status !== "revision_requested") { throw unprocessable("Only revision requested approvals can be resubmitted"); } const now = new Date(); return db .update(approvals) .set({ status: "pending", payload: payload ?? existing.payload, decisionNote: null, decidedByUserId: null, decidedAt: null, updatedAt: now, }) .where(eq(approvals.id, id)) .returning() .then((rows) => rows[0]); }, listComments: async (approvalId: string) => { const existing = await getExistingApproval(approvalId); return db .select() .from(approvalComments) .where( and( eq(approvalComments.approvalId, approvalId), eq(approvalComments.companyId, existing.companyId), ), ) .orderBy(asc(approvalComments.createdAt)); }, addComment: async ( approvalId: string, body: string, actor: { agentId?: string; userId?: string }, ) => { const existing = await getExistingApproval(approvalId); return db .insert(approvalComments) .values({ companyId: existing.companyId, approvalId, authorAgentId: actor.agentId ?? null, authorUserId: actor.userId ?? null, body, }) .returning() .then((rows) => rows[0]); }, }; }