import type { DatabaseSync } from "node:sqlite"; import type { CreateApprovalInput, DecideApprovalInput } from "../../shared/commands"; import { APPROVAL_STATE, ROLE_KEY, TASK_STATUS } from "../../shared/domain"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; import { settleHire } from "./agent-admission"; import { settleHeldDeliverable } from "./held-deliverables"; import { OrganizationService } from "./organization-service"; import { IdentityAccessService } from "./identity-access-service"; import { OrgGovernanceService } from "./org-governance-service"; interface ApprovalAuditContext { sourceMessageId?: string; sourceThreadId?: string; sourceEntityType?: string; sourceEntityId?: string; } export class ApprovalService { private readonly audit: AuditService; private readonly identityAccess: IdentityAccessService; private readonly governance: OrgGovernanceService; constructor(private readonly db: DatabaseSync) { this.audit = new AuditService(db); this.identityAccess = new IdentityAccessService(db); this.governance = new OrgGovernanceService(db); } create(input: CreateApprovalInput, context: ApprovalAuditContext = {}): string { this.assertRequesterBelongsToOrg(input); const kind = input.kind.trim(); const title = input.title.trim(); const details = input.details.trim(); if (!kind) throw new Error("Approval kind is required."); if (!title) throw new Error("Approval title is required."); if (!details) throw new Error("Approval details are required."); const id = createId("app"); const now = new Date().toISOString(); this.db.prepare(` insert into approvals( id, org_id, kind, title, details, risk, requested_by_type, requested_by_id, source_entity_type, source_entity_id, state, decided_by_employee_id, created_at, decided_at ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, input.orgId, kind, title, details, input.risk, input.requestedByType, input.requestedById, context.sourceEntityType ?? null, context.sourceEntityId ?? null, APPROVAL_STATE.PENDING, null, now, null, ); this.audit.record({ orgId: input.orgId, entityType: "approval", entityId: id, actorType: input.requestedByType, actorId: input.requestedById, action: "requested", payload: { kind, risk: input.risk, ...context }, }); this.governance.routeApproval({ orgId: input.orgId, approvalId: id, kind, risk: input.risk }); return id; } decide(input: DecideApprovalInput): void { this.identityAccess.assertEmployeeRole( input.orgId, input.decidedByEmployeeId, ROLE_KEY.APPROVAL_DECIDER, "approval_decide", "approval", input.approvalId, "Deciding employee was not found.", ); const now = new Date().toISOString(); const notes = input.notes?.trim() || null; const result = this.db.prepare(` update approvals set state = ?, decided_by_employee_id = ?, decided_at = ?, decision_notes = ? where id = ? and org_id = ? and state = ? `).run(input.state, input.decidedByEmployeeId, now, notes, input.approvalId, input.orgId, APPROVAL_STATE.PENDING); if (result.changes === 0) throw new Error("Pending approval was not found."); const approval = this.db.prepare("select kind, source_entity_type, source_entity_id from approvals where id = ?") .get(input.approvalId) as { kind: string; source_entity_type: string | null; source_entity_id: string | null }; if (approval.kind === "agent_hire" && approval.source_entity_type === "agent" && approval.source_entity_id) { settleHire(this.db, input.orgId, approval.source_entity_id, input.state === APPROVAL_STATE.APPROVED); } this.audit.record({ orgId: input.orgId, entityType: "approval", entityId: input.approvalId, actorType: "employee", actorId: input.decidedByEmployeeId, action: input.state, payload: { decidedAt: now }, }); // Last, so nothing after it can fail once a file is on disk. if (approval.source_entity_type === "held_deliverable" && approval.source_entity_id) { const approved = input.state === APPROVAL_STATE.APPROVED; const taskId = settleHeldDeliverable(this.db, approval.source_entity_id, approved); if (!approved && taskId) this.reopenForRework(input.orgId, taskId); } } /** Declined work goes back to its task's assignee: the task returns to in progress, wherever it had got to. */ private reopenForRework(orgId: string, taskId: string): void { const task = this.db.prepare("select status from tasks where id = ? and org_id = ?").get(taskId, orgId) as { status: string } | undefined; if (!task || task.status === TASK_STATUS.IN_PROGRESS) return; new OrganizationService(this.db).updateTaskStatus(orgId, taskId, TASK_STATUS.IN_PROGRESS); console.info(`[deliverables] task ${taskId} reopened for rework after a declined deliverable`); } private assertRequesterBelongsToOrg(input: CreateApprovalInput): void { if (input.requestedByType === "employee") { if (!input.requestedById) throw new Error("Requesting employee is required."); this.assertEmployee(input.orgId, input.requestedById, "Requesting employee was not found."); return; } if (input.requestedByType === "agent") { if (!input.requestedById) throw new Error("Requesting agent is required."); const agent = this.db.prepare("select id from agents where id = ? and org_id = ?").get(input.requestedById, input.orgId); if (!agent) throw new Error("Requesting agent was not found."); return; } if (input.requestedByType === "system" && input.requestedById) { throw new Error("System approvals cannot include a requester id."); } } private assertEmployee(orgId: string, employeeId: string, message: string): void { const employee = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId); if (!employee) throw new Error(message); } }