Spaces:
Paused
Paused
| import { and, asc, eq, inArray } from "drizzle-orm"; | |
| import type { Db } from "@paperclipai/db"; | |
| import { approvalComments, approvals } from "@paperclipai/db"; | |
| import { notFound, unprocessable } from "../errors.js"; | |
| import { redactCurrentUserText } from "../log-redaction.js"; | |
| import { agentService } from "./agents.js"; | |
| import { budgetService } from "./budgets.js"; | |
| import { notifyHireApproved } from "./hire-hook.js"; | |
| function redactApprovalComment<T extends { body: string }>(comment: T): T { | |
| return { | |
| ...comment, | |
| body: redactCurrentUserText(comment.body), | |
| }; | |
| } | |
| export function approvalService(db: Db) { | |
| const agentsSvc = agentService(db); | |
| const budgets = budgetService(db); | |
| const canResolveStatuses = new Set(["pending", "revision_requested"]); | |
| const resolvableStatuses = Array.from(canResolveStatuses); | |
| type ApprovalRecord = typeof approvals.$inferSelect; | |
| type ResolutionResult = { approval: ApprovalRecord; applied: boolean }; | |
| 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; | |
| } | |
| async function resolveApproval( | |
| id: string, | |
| targetStatus: "approved" | "rejected", | |
| decidedByUserId: string, | |
| decisionNote: string | null | undefined, | |
| ): Promise<ResolutionResult> { | |
| const existing = await getExistingApproval(id); | |
| if (!canResolveStatuses.has(existing.status)) { | |
| if (existing.status === targetStatus) { | |
| return { approval: existing, applied: false }; | |
| } | |
| throw unprocessable( | |
| `Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`, | |
| ); | |
| } | |
| const now = new Date(); | |
| const updated = await db | |
| .update(approvals) | |
| .set({ | |
| status: targetStatus, | |
| decidedByUserId, | |
| decisionNote: decisionNote ?? null, | |
| decidedAt: now, | |
| updatedAt: now, | |
| }) | |
| .where(and(eq(approvals.id, id), inArray(approvals.status, resolvableStatuses))) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| if (updated) { | |
| return { approval: updated, applied: true }; | |
| } | |
| const latest = await getExistingApproval(id); | |
| if (latest.status === targetStatus) { | |
| return { approval: latest, applied: false }; | |
| } | |
| throw unprocessable( | |
| `Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`, | |
| ); | |
| } | |
| 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<typeof approvals.$inferInsert, "companyId">) => | |
| db | |
| .insert(approvals) | |
| .values({ ...data, companyId }) | |
| .returning() | |
| .then((rows) => rows[0]), | |
| approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => { | |
| const { approval: updated, applied } = await resolveApproval( | |
| id, | |
| "approved", | |
| decidedByUserId, | |
| decisionNote, | |
| ); | |
| let hireApprovedAgentId: string | null = null; | |
| const now = new Date(); | |
| if (applied && updated.type === "hire_agent") { | |
| const payload = updated.payload as Record<string, unknown>; | |
| 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<string, unknown>) | |
| : {}, | |
| budgetMonthlyCents: | |
| typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0, | |
| metadata: | |
| typeof payload.metadata === "object" && payload.metadata !== null | |
| ? (payload.metadata as Record<string, unknown>) | |
| : null, | |
| status: "idle", | |
| spentMonthlyCents: 0, | |
| permissions: undefined, | |
| lastHeartbeatAt: null, | |
| }); | |
| hireApprovedAgentId = created?.id ?? null; | |
| } | |
| if (hireApprovedAgentId) { | |
| const budgetMonthlyCents = | |
| typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0; | |
| if (budgetMonthlyCents > 0) { | |
| await budgets.upsertPolicy( | |
| updated.companyId, | |
| { | |
| scopeType: "agent", | |
| scopeId: hireApprovedAgentId, | |
| amount: budgetMonthlyCents, | |
| windowKind: "calendar_month_utc", | |
| }, | |
| decidedByUserId, | |
| ); | |
| } | |
| void notifyHireApproved(db, { | |
| companyId: updated.companyId, | |
| agentId: hireApprovedAgentId, | |
| source: "approval", | |
| sourceId: id, | |
| approvedAt: now, | |
| }).catch(() => {}); | |
| } | |
| } | |
| return { approval: updated, applied }; | |
| }, | |
| reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => { | |
| const { approval: updated, applied } = await resolveApproval( | |
| id, | |
| "rejected", | |
| decidedByUserId, | |
| decisionNote, | |
| ); | |
| if (applied && updated.type === "hire_agent") { | |
| const payload = updated.payload as Record<string, unknown>; | |
| const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null; | |
| if (payloadAgentId) { | |
| await agentsSvc.terminate(payloadAgentId); | |
| } | |
| } | |
| return { approval: updated, applied }; | |
| }, | |
| 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<string, unknown>) => { | |
| 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)) | |
| .then((comments) => comments.map(redactApprovalComment)); | |
| }, | |
| addComment: async ( | |
| approvalId: string, | |
| body: string, | |
| actor: { agentId?: string; userId?: string }, | |
| ) => { | |
| const existing = await getExistingApproval(approvalId); | |
| const redactedBody = redactCurrentUserText(body); | |
| return db | |
| .insert(approvalComments) | |
| .values({ | |
| companyId: existing.companyId, | |
| approvalId, | |
| authorAgentId: actor.agentId ?? null, | |
| authorUserId: actor.userId ?? null, | |
| body: redactedBody, | |
| }) | |
| .returning() | |
| .then((rows) => redactApprovalComment(rows[0])); | |
| }, | |
| }; | |
| } | |