import { eq } from "drizzle-orm"; import { Hono } from "hono"; import type { Db } from "../db/client.js"; import { activityLog, approvalPolicies, approvalSteps, invoiceComments, invoices, } from "../db/schema/index.js"; import type { AppBindings } from "../auth/middleware.js"; import { errorJson } from "../http.js"; import { asMoney, asString, caller, firstById, iso, isUuid, parseJsonBody, requireCan, rowsOf, } from "./helpers.js"; import { appendActivity, remainingPending } from "../approvals.js"; import type { UserRole } from "../env.js"; type PolicyRow = typeof approvalPolicies.$inferSelect; type StepRow = typeof approvalSteps.$inferSelect; type InvoiceRow = typeof invoices.$inferSelect; type CommentRow = typeof invoiceComments.$inferSelect; type ActivityRow = typeof activityLog.$inferSelect; const DECISIONS = ["approve", "reject", "skip"] as const; type Decision = (typeof DECISIONS)[number]; function isDecision(value: string): value is Decision { return (DECISIONS as readonly string[]).includes(value); } function toPolicy(row: PolicyRow) { return { id: row.id, name: row.name, priority: row.priority, amountThreshold: row.amountThreshold, skipBelowAmount: row.skipBelowAmount, active: row.active, createdAt: iso(row.createdAt), updatedAt: iso(row.updatedAt), }; } function toStep(row: StepRow) { return { id: row.id, invoiceId: row.invoiceId, policyId: row.policyId, stepOrder: row.stepOrder, approverRole: row.approverRole, assigneeUserId: row.assigneeUserId, status: row.status, actedByUserId: row.actedByUserId, actedAt: row.actedAt ? iso(row.actedAt) : null, createdAt: iso(row.createdAt), }; } function inboxVisible(step: StepRow, user: { id: string; role: UserRole }): boolean { if (step.status !== "pending") return false; if (step.assigneeUserId) return step.assigneeUserId === user.id; return user.role === "admin" || user.role === step.approverRole; } export function createApprovalRoutes(handle: Db) { const routes = new Hono(); routes.get("/approval-policies", async (c) => { const denied = requireCan(c, "read:invoices"); if (denied) return denied; const rows = await rowsOf(handle, approvalPolicies); return c.json({ items: rows.map(toPolicy) }); }); routes.post("/approval-policies", async (c) => { const denied = requireCan(c, "admin:settings"); if (denied) return denied; const body = await parseJsonBody(c); const name = asString(body.name).trim(); if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required."); const [row] = await handle.db .insert(approvalPolicies) .values({ name, priority: typeof body.priority === "number" ? body.priority : 100, amountThreshold: asMoney(body.amountThreshold), skipBelowAmount: asMoney(body.skipBelowAmount), active: body.active === false ? false : true, }) .returning(); return c.json(toPolicy(row), 201); }); routes.get("/approval-policies/:id", async (c) => { const denied = requireCan(c, "read:invoices"); if (denied) return denied; const id = c.req.param("id"); if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid policy id."); const row = await firstById(handle, approvalPolicies, id); if (!row) return errorJson(c, 404, "NOT_FOUND", "Policy not found."); return c.json(toPolicy(row)); }); routes.patch("/approval-policies/:id", async (c) => { const denied = requireCan(c, "admin:settings"); if (denied) return denied; const id = c.req.param("id"); if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid policy id."); const existing = await firstById(handle, approvalPolicies, id); if (!existing) return errorJson(c, 404, "NOT_FOUND", "Policy not found."); const body = await parseJsonBody(c); const patch: Partial & { id: string } = { id, updatedAt: new Date(), }; if (body.name !== undefined) { const name = asString(body.name).trim(); if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required."); patch.name = name; } if (typeof body.priority === "number") patch.priority = body.priority; if (body.amountThreshold !== undefined) patch.amountThreshold = asMoney(body.amountThreshold); if (body.skipBelowAmount !== undefined) patch.skipBelowAmount = asMoney(body.skipBelowAmount); if (typeof body.active === "boolean") patch.active = body.active; const [row] = await handle.db .update(approvalPolicies) .set(patch) .where(eq(approvalPolicies.id, id)) .returning(); return c.json(toPolicy(row)); }); routes.get("/inbox", async (c) => { const denied = requireCan(c, "read:invoices"); if (denied) return denied; const user = caller(c); const steps = await rowsOf(handle, approvalSteps); const invoiceRows = await rowsOf(handle, invoices); const items = steps .filter((step) => inboxVisible(step, user)) .map((step) => { const invoice = invoiceRows.find((row) => row.id === step.invoiceId); return { ...toStep(step), invoiceNumber: invoice?.invoiceNumber ?? null, amount: invoice?.amount ?? null, vendorId: invoice?.vendorId ?? null, }; }); return c.json({ items }); }); routes.post("/approval-steps/:id/decisions", async (c) => { const denied = requireCan(c, "approve:invoices"); if (denied) return denied; const id = c.req.param("id"); if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid step id."); const step = await firstById(handle, approvalSteps, id); if (!step) return errorJson(c, 404, "NOT_FOUND", "Approval step not found."); if (step.status !== "pending") { return errorJson(c, 409, "CONFLICT", "Step is not pending."); } const body = await parseJsonBody(c); const action = asString(body.action); if (!isDecision(action)) { return errorJson(c, 400, "VALIDATION_ERROR", "action must be approve, reject, or skip."); } const user = caller(c); const nextStatus = action === "approve" ? "approved" : action === "reject" ? "rejected" : "skipped"; const [updated] = await handle.db .update(approvalSteps) .set({ id, status: nextStatus, actedByUserId: user.id, actedAt: new Date(), }) .where(eq(approvalSteps.id, id)) .returning(); await appendActivity( handle, step.invoiceId, user.id, action === "approve" ? "Step approved." : action === "reject" ? "Step rejected." : "Step skipped.", ); let invoiceStatus: InvoiceRow["status"] | undefined; if (action === "reject") invoiceStatus = "rejected"; else if ((await remainingPending(handle, step.invoiceId)).length === 0) { invoiceStatus = "approved"; } if (invoiceStatus) { await handle.db .update(invoices) .set({ id: step.invoiceId, status: invoiceStatus, updatedAt: new Date() }) .where(eq(invoices.id, step.invoiceId)) .returning(); } return c.json(toStep(updated)); }); routes.get("/invoices/:id/comments", async (c) => { const denied = requireCan(c, "read:invoices"); if (denied) return denied; const id = c.req.param("id"); if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); const invoice = await firstById(handle, invoices, id); if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); const rows = (await rowsOf(handle, invoiceComments)).filter( (row) => row.invoiceId === id, ); return c.json({ items: rows.map((row) => ({ id: row.id, invoiceId: row.invoiceId, authorUserId: row.authorUserId, body: row.body, createdAt: iso(row.createdAt), })), }); }); routes.post("/invoices/:id/comments", async (c) => { const denied = requireCan(c, "read:invoices"); if (denied) return denied; const id = c.req.param("id"); if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); const invoice = await firstById(handle, invoices, id); if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); const body = asString((await parseJsonBody(c)).body).trim(); if (!body) return errorJson(c, 400, "VALIDATION_ERROR", "body is required."); const user = caller(c); const [row] = await handle.db .insert(invoiceComments) .values({ invoiceId: id, authorUserId: user.id, body }) .returning(); await appendActivity(handle, id, user.id, "Comment added."); return c.json( { id: row.id, invoiceId: row.invoiceId, authorUserId: row.authorUserId, body: row.body, createdAt: iso(row.createdAt), }, 201, ); }); routes.get("/invoices/:id/activity-logs", async (c) => { const denied = requireCan(c, "read:invoices"); if (denied) return denied; const id = c.req.param("id"); if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); const invoice = await firstById(handle, invoices, id); if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); const rows = (await rowsOf(handle, activityLog)).filter( (row) => row.invoiceId === id, ); return c.json({ items: rows.map((row) => ({ id: row.id, invoiceId: row.invoiceId, actorUserId: row.actorUserId, message: row.message, createdAt: iso(row.createdAt), })), }); }); return routes; }