seahaven-ap/packages/api/src/routes/approvals.ts

285 lines
9.8 KiB
TypeScript

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<AppBindings>();
routes.get("/approval-policies", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const rows = await rowsOf<PolicyRow>(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<PolicyRow>(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<PolicyRow>(handle, approvalPolicies, id);
if (!existing) return errorJson(c, 404, "NOT_FOUND", "Policy not found.");
const body = await parseJsonBody(c);
const patch: Partial<typeof approvalPolicies.$inferInsert> & { 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<StepRow>(handle, approvalSteps);
const invoiceRows = await rowsOf<InvoiceRow>(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<StepRow>(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<InvoiceRow>(handle, invoices, id);
if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
const rows = (await rowsOf<CommentRow>(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<InvoiceRow>(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<InvoiceRow>(handle, invoices, id);
if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
const rows = (await rowsOf<ActivityRow>(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;
}