mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-10-07 16:18:53 +00:00
285 lines
9.8 KiB
TypeScript
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;
|
|
}
|