feat(api): add approval policy, inbox, and activity stubs

This commit is contained in:
Adam Moussa 2026-09-22 12:51:50 -04:00
parent f08b538a19
commit 9ae54d4a2a
No known key found for this signature in database
16 changed files with 1047 additions and 11 deletions

View file

@ -352,3 +352,135 @@ Document:
downloadUrl:
type: string
description: Presigned GET URL returned on confirm or get.
ApprovalPolicy:
type: object
required: [id, name, priority, active, createdAt, updatedAt]
properties:
id:
type: string
format: uuid
description: Policy primary key.
example: cccccccc-cccc-4ccc-8ccc-cccccccccccc
name:
type: string
description: Policy display name.
example: Default approver policy
priority:
type: integer
description: Lower numbers match first.
example: 10
amountThreshold:
type: [string, "null"]
description: Minimum invoice amount that matches this policy.
example: "0.00"
skipBelowAmount:
type: [string, "null"]
description: Amounts below this skip the approval step.
example: "25.00"
active:
type: boolean
description: Whether the policy is considered when matching.
example: true
createdAt:
type: string
format: date-time
description: Row creation time.
updatedAt:
type: string
format: date-time
description: Row update time.
ApprovalStep:
type: object
required: [id, invoiceId, stepOrder, approverRole, status, createdAt]
properties:
id:
type: string
format: uuid
description: Step primary key.
example: eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee
invoiceId:
type: string
format: uuid
description: Parent invoice id.
policyId:
type: [string, "null"]
format: uuid
description: Policy that created the step.
stepOrder:
type: integer
description: Order within the invoice.
example: 1
approverRole:
type: string
enum: [admin, ap_processor, approver, viewer]
description: Role allowed to act when no assignee is set.
example: approver
assigneeUserId:
type: [string, "null"]
format: uuid
description: Optional assigned user.
status:
type: string
enum: [pending, approved, rejected, skipped]
description: Step status.
example: pending
actedByUserId:
type: [string, "null"]
format: uuid
description: User who acted.
actedAt:
type: [string, "null"]
format: date-time
description: When the step was acted on.
createdAt:
type: string
format: date-time
description: Row creation time.
InvoiceComment:
type: object
required: [id, invoiceId, body, createdAt]
properties:
id:
type: string
format: uuid
description: Comment primary key.
invoiceId:
type: string
format: uuid
description: Parent invoice id.
authorUserId:
type: [string, "null"]
format: uuid
description: Author user id.
body:
type: string
description: Comment text.
example: Looks good.
createdAt:
type: string
format: date-time
description: Row creation time.
ActivityLog:
type: object
required: [id, invoiceId, message, createdAt]
properties:
id:
type: string
format: uuid
description: Activity row primary key.
invoiceId:
type: string
format: uuid
description: Parent invoice id.
actorUserId:
type: [string, "null"]
format: uuid
description: Actor user id.
message:
type: string
description: Activity message.
example: Step approved.
createdAt:
type: string
format: date-time
description: Row creation time.

View file

@ -19,6 +19,8 @@ tags:
description: Invoice headers, coding lines, and uniqueness rules.
- name: Documents
description: Presigned document upload, confirm, and download against MinIO or S3.
- name: Approvals
description: Approval policies, step decisions, inbox, comments, and activity.
paths:
/api/health:
$ref: ./paths/health.yaml
@ -62,6 +64,18 @@ paths:
$ref: ./paths/documents-id.yaml
/api/documents/{id}/confirmations:
$ref: ./paths/documents-id-confirmations.yaml
/api/approval-policies:
$ref: ./paths/approval-policies.yaml
/api/approval-policies/{id}:
$ref: ./paths/approval-policies-id.yaml
/api/approval-steps/{id}/decisions:
$ref: ./paths/approval-steps-id-decisions.yaml
/api/inbox:
$ref: ./paths/inbox.yaml
/api/invoices/{id}/comments:
$ref: ./paths/invoices-id-comments.yaml
/api/invoices/{id}/activity-logs:
$ref: ./paths/invoices-id-activity-logs.yaml
components:
securitySchemes:
cookieAuth:
@ -89,5 +103,13 @@ components:
$ref: ./components/schemas.yaml#/InvoiceLine
Document:
$ref: ./components/schemas.yaml#/Document
ApprovalPolicy:
$ref: ./components/schemas.yaml#/ApprovalPolicy
ApprovalStep:
$ref: ./components/schemas.yaml#/ApprovalStep
InvoiceComment:
$ref: ./components/schemas.yaml#/InvoiceComment
ActivityLog:
$ref: ./components/schemas.yaml#/ActivityLog
security:
- cookieAuth: []

View file

@ -0,0 +1,91 @@
parameters:
- name: id
in: path
required: true
description: Policy primary key.
schema:
type: string
format: uuid
example: cccccccc-cccc-4ccc-8ccc-cccccccccccc
get:
tags: [Approvals]
summary: Get an approval policy
description: Returns one approval policy by id.
operationId: get-api-approval-policies-id
responses:
"200":
description: Policy.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/ApprovalPolicy
"400":
description: Invalid id.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Policy not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
patch:
tags: [Approvals]
summary: Update an approval policy
description: Admin-only patch. Requires admin:settings.
operationId: patch-api-approval-policies-id
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
example: Default approver policy
priority:
type: integer
example: 10
amountThreshold:
type: string
example: "0.00"
skipBelowAmount:
type: string
example: "25.00"
active:
type: boolean
example: true
responses:
"200":
description: Updated policy.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/ApprovalPolicy
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks admin:settings.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Policy not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,71 @@
get:
tags: [Approvals]
summary: List approval policies
description: Returns every approval policy.
operationId: get-api-approval-policies
responses:
"200":
description: Policy list.
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: ../components/schemas.yaml#/ApprovalPolicy
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
post:
tags: [Approvals]
summary: Create an approval policy
description: Admin-only insert. Requires admin:settings.
operationId: post-api-approval-policies
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
example: High dollar
priority:
type: integer
example: 1
amountThreshold:
type: string
example: "500.00"
skipBelowAmount:
type: string
example: "25.00"
active:
type: boolean
example: true
responses:
"201":
description: Created policy.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/ApprovalPolicy
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks admin:settings.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,57 @@
parameters:
- name: id
in: path
required: true
description: Approval step primary key.
schema:
type: string
format: uuid
example: eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee
post:
tags: [Approvals]
summary: Record an approval decision
description: Approves, rejects, or skips a pending step. Requires approve:invoices.
operationId: post-api-approval-steps-id-decisions
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [action]
properties:
action:
type: string
enum: [approve, reject, skip]
example: approve
responses:
"200":
description: Updated step.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/ApprovalStep
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks approve:invoices.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Step not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"409":
description: Step is not pending.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,24 @@
get:
tags: [Approvals]
summary: List the caller approval inbox
description: Returns pending steps visible to the caller.
operationId: get-api-inbox
responses:
"200":
description: Inbox items.
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: ../components/schemas.yaml#/ApprovalStep
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,45 @@
parameters:
- name: id
in: path
required: true
description: Invoice primary key.
schema:
type: string
format: uuid
example: 88888888-8888-4888-8888-888888888888
get:
tags: [Approvals]
summary: List invoice activity
description: Returns activity log rows for one invoice.
operationId: get-api-invoices-id-activity-logs
responses:
"200":
description: Activity list.
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: ../components/schemas.yaml#/ActivityLog
"400":
description: Invalid id.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Invoice not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,80 @@
parameters:
- name: id
in: path
required: true
description: Invoice primary key.
schema:
type: string
format: uuid
example: 88888888-8888-4888-8888-888888888888
get:
tags: [Approvals]
summary: List invoice comments
description: Returns comments on one invoice.
operationId: get-api-invoices-id-comments
responses:
"200":
description: Comment list.
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: ../components/schemas.yaml#/InvoiceComment
"400":
description: Invalid id.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Invoice not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
post:
tags: [Approvals]
summary: Add an invoice comment
description: Appends a comment and an activity row.
operationId: post-api-invoices-id-comments
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [body]
properties:
body:
type: string
example: Looks good.
responses:
"201":
description: Created comment.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/InvoiceComment
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Invoice not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -10,6 +10,7 @@ import { createGlAccountRoutes } from "./routes/gl-accounts.js";
import { createDepartmentRoutes } from "./routes/departments.js";
import { createUserRoutes } from "./routes/users.js";
import { createInvoiceRoutes } from "./routes/invoices.js";
import { createApprovalRoutes } from "./routes/approvals.js";
import { createDocumentsStore, type DocumentsStore } from "./documents.js";
import { errorJson } from "./http.js";
import { cloudFrontOriginAllowed } from "./auth/origin-verify.js";
@ -52,6 +53,7 @@ export function createApp(env: ApiEnv, handle: Db, deps: AppDeps = {}) {
api.route("/", createDepartmentRoutes(handle));
api.route("/", createUserRoutes(handle));
api.route("/", createInvoiceRoutes(handle, deps.documents ?? createDocumentsStore(env)));
api.route("/", createApprovalRoutes(handle));
app.route("/api", api);
app.notFound((c) => errorJson(c, 404, "NOT_FOUND", "Not found."));

View file

@ -0,0 +1,74 @@
import { eq } from "drizzle-orm";
import type { Db } from "./db/client.js";
import { activityLog, approvalPolicies, approvalSteps, invoices } from "./db/schema/index.js";
import { moneyCents, rowsOf } from "./routes/helpers.js";
type InvoiceRow = typeof invoices.$inferSelect;
type PolicyRow = typeof approvalPolicies.$inferSelect;
type StepRow = typeof approvalSteps.$inferSelect;
export async function appendActivity(
handle: Db,
invoiceId: string,
actorUserId: string,
message: string,
) {
await handle.db.insert(activityLog).values({ invoiceId, actorUserId, message }).returning();
}
export async function applyMatchingPolicy(
handle: Db,
invoice: InvoiceRow,
actorUserId: string,
): Promise<InvoiceRow> {
await appendActivity(handle, invoice.id, actorUserId, "Invoice created.");
const policies = (await rowsOf<PolicyRow>(handle, approvalPolicies))
.filter((policy) => policy.active)
.sort((a, b) => a.priority - b.priority);
const amount = moneyCents(invoice.amount);
const matched = policies.find((policy) => {
if (policy.amountThreshold == null) return true;
return amount >= moneyCents(policy.amountThreshold);
});
if (!matched) return invoice;
const skip = matched.skipBelowAmount != null && amount < moneyCents(matched.skipBelowAmount);
await handle.db
.insert(approvalSteps)
.values({
invoiceId: invoice.id,
policyId: matched.id,
stepOrder: 1,
approverRole: "approver",
status: skip ? "skipped" : "pending",
actedByUserId: skip ? actorUserId : null,
actedAt: skip ? new Date() : null,
})
.returning();
if (!skip) {
await appendActivity(
handle,
invoice.id,
actorUserId,
`Approval required by policy ${matched.name}.`,
);
return invoice;
}
await appendActivity(
handle,
invoice.id,
actorUserId,
`Step skipped because amount is below ${matched.skipBelowAmount}.`,
);
const [updated] = await handle.db
.update(invoices)
.set({ id: invoice.id, status: "approved", updatedAt: new Date() })
.where(eq(invoices.id, invoice.id))
.returning();
return updated ?? { ...invoice, status: "approved" };
}
export async function remainingPending(handle: Db, invoiceId: string): Promise<StepRow[]> {
const rows = await rowsOf<StepRow>(handle, approvalSteps);
return rows.filter((row) => row.invoiceId === invoiceId && row.status === "pending");
}

View file

@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { createApp } from "../app.js";
import { createMemoryDocumentsStore } from "../documents.js";
import { loadEnv } from "../env.js";
import type { ErrorEnvelope } from "../http.js";
import { createFakeDb, SEED } from "../test/fake-db.js";
function expectEnvelope(body: unknown, code: string) {
const envelope = body as ErrorEnvelope;
expect(envelope.error.code).toBe(code);
}
function envFor(role: "admin" | "approver" | "viewer") {
return loadEnv({
NODE_ENV: "test",
DEV_AUTH_BYPASS: "true",
DEV_AUTH_SUB: SEED.user.cognitoSub,
DEV_AUTH_EMAIL: SEED.user.email,
DEV_AUTH_NAME: SEED.user.name,
DEV_AUTH_ROLE: role,
});
}
const jsonHeaders = {
"content-type": "application/json",
origin: "http://127.0.0.1:3000",
};
function app(role: "admin" | "approver" | "viewer" = "admin") {
return createApp(envFor(role), createFakeDb(), { documents: createMemoryDocumentsStore() });
}
describe("approval stubs", () => {
it("skips a step when the invoice is below skipBelowAmount", async () => {
const api = app();
const response = await api.request("/api/invoices", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({
vendorId: SEED.vendor.id,
invoiceNumber: "INV-SKIP",
amount: "10.00",
dueDate: "2026-10-01",
}),
});
expect(response.status).toBe(201);
const invoice = (await response.json()) as { id: string; status: string };
expect(invoice.status).toBe("approved");
const activity = await api.request(`/api/invoices/${invoice.id}/activity-logs`);
expect(activity.status).toBe(200);
const log = (await activity.json()) as { items: Array<{ message: string }> };
expect(log.items.some((item) => item.message.includes("below"))).toBe(true);
});
it("approves a pending step and writes activity", async () => {
const api = app("approver");
const response = await api.request(`/api/approval-steps/${SEED.step.id}/decisions`, {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ action: "approve" }),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ status: "approved" });
const activity = await api.request(`/api/invoices/${SEED.invoice.id}/activity-logs`);
const log = (await activity.json()) as { items: Array<{ message: string }> };
expect(log.items.some((item) => item.message === "Step approved.")).toBe(true);
});
it("returns 403 when a viewer acts on a step", async () => {
const api = app("viewer");
const response = await api.request(`/api/approval-steps/${SEED.step.id}/decisions`, {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ action: "approve" }),
});
expect(response.status).toBe(403);
expectEnvelope(await response.json(), "FORBIDDEN");
});
it("lists the caller inbox and accepts a comment", async () => {
const api = app("admin");
const inbox = await api.request("/api/inbox");
expect(inbox.status).toBe(200);
const listed = (await inbox.json()) as { items: Array<{ id: string }> };
expect(listed.items[0]?.id).toBe(SEED.step.id);
const comment = await api.request(`/api/invoices/${SEED.invoice.id}/comments`, {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ body: "Looks good." }),
});
expect(comment.status).toBe(201);
await expect(comment.json()).resolves.toMatchObject({ body: "Looks good." });
});
it("creates an approval policy as admin", async () => {
const api = app("admin");
const response = await api.request("/api/approval-policies", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({
name: "High dollar",
priority: 1,
amountThreshold: "500.00",
skipBelowAmount: "0.00",
}),
});
expect(response.status).toBe(201);
await expect(response.json()).resolves.toMatchObject({ name: "High dollar", priority: 1 });
});
});

View file

@ -0,0 +1,285 @@
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;
}

View file

@ -5,6 +5,7 @@ import type { DocumentsStore } from "../documents.js";
import { documents, invoiceLines, invoices, vendors } from "../db/schema/index.js";
import type { AppBindings } from "../auth/middleware.js";
import { errorJson } from "../http.js";
import { applyMatchingPolicy } from "../approvals.js";
import {
asMoney,
asString,
@ -246,7 +247,8 @@ export function createInvoiceRoutes(handle: Db, store: DocumentsStore) {
.values({ invoiceId: row.id, ...line })
.returning();
}
return c.json(toInvoice(row, await linesFor(handle, row.id)), 201);
const latest = await applyMatchingPolicy(handle, row, caller(c).id);
return c.json(toInvoice(latest, await linesFor(handle, row.id)), 201);
});
routes.patch("/invoices/:id", async (c) => {
@ -257,7 +259,7 @@ export function createInvoiceRoutes(handle: Db, store: DocumentsStore) {
const existing = await firstById<InvoiceRow>(handle, invoices, id);
if (!existing) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
const body = await parseJsonBody(c);
const patch: Partial<typeof invoices.$inferInsert> = { updatedAt: new Date() };
const patch: Partial<typeof invoices.$inferInsert> = { id, updatedAt: new Date() };
if (body.vendorId !== undefined) {
const vendorId = asString(body.vendorId);
if (!isUuid(vendorId)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid vendorId.");

View file

@ -68,6 +68,42 @@ export const SEED = {
uploadedByUserId: "11111111-1111-4111-8111-111111111111",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
},
policy: {
id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
name: "Default approver policy",
priority: 10,
amountThreshold: "0.00",
skipBelowAmount: "25.00",
active: true,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
},
step: {
id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
invoiceId: "88888888-8888-4888-8888-888888888888",
policyId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
stepOrder: 1,
approverRole: "approver" as UserRole,
assigneeUserId: null as string | null,
status: "pending" as const,
actedByUserId: null as string | null,
actedAt: null as Date | null,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
},
comment: {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
invoiceId: "88888888-8888-4888-8888-888888888888",
authorUserId: "11111111-1111-4111-8111-111111111111",
body: "Seed comment on INV-1001.",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
},
activity: {
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
invoiceId: "88888888-8888-4888-8888-888888888888",
actorUserId: "11111111-1111-4111-8111-111111111111",
message: "Invoice created from seed.",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
},
};
export type Store = {
@ -78,10 +114,10 @@ export type Store = {
invoices: Array<typeof SEED.invoice>;
invoiceLines: Array<typeof SEED.line>;
documents: Array<typeof SEED.document>;
approvalPolicies: Array<Record<string, unknown>>;
approvalSteps: Array<Record<string, unknown>>;
invoiceComments: Array<Record<string, unknown>>;
activityLog: Array<Record<string, unknown>>;
approvalPolicies: Array<typeof SEED.policy>;
approvalSteps: Array<typeof SEED.step>;
invoiceComments: Array<typeof SEED.comment>;
activityLog: Array<typeof SEED.activity>;
};
export function emptyStore(): Store {
@ -93,10 +129,10 @@ export function emptyStore(): Store {
invoices: [{ ...SEED.invoice }],
invoiceLines: [{ ...SEED.line }],
documents: [{ ...SEED.document }],
approvalPolicies: [],
approvalSteps: [],
invoiceComments: [],
activityLog: [],
approvalPolicies: [{ ...SEED.policy }],
approvalSteps: [{ ...SEED.step }],
invoiceComments: [{ ...SEED.comment }],
activityLog: [{ ...SEED.activity }],
};
}
@ -182,7 +218,8 @@ export function createFakeDb(store: Store = emptyStore()): Db {
where: vi.fn(() => ({
returning: vi.fn(async () => {
const rows = rowsFor(store, table);
const current = rows[0];
const current =
(typeof patch.id === "string" && rows.find((row) => row.id === patch.id)) || rows[0];
if (!current) return [];
Object.assign(current, patch);
return [current];

View file

@ -72,6 +72,7 @@ rules:
- callback
- refresh
- logout
- inbox
paths-kebab-case: error
no-invalid-schema-examples: error
# No schema-properties casing rule: property names mirror the DynamoDB

View file

@ -117,4 +117,7 @@ export type ApiPaths = {
"/api/documents/{id}": {
get: { response: Document };
};
"/api/inbox": {
get: { response: { items: Array<{ id: string; invoiceId: string; status: string }> } };
};
};