diff --git a/packages/api/openapi/components/schemas.yaml b/packages/api/openapi/components/schemas.yaml index bd6be25..98c2009 100644 --- a/packages/api/openapi/components/schemas.yaml +++ b/packages/api/openapi/components/schemas.yaml @@ -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. diff --git a/packages/api/openapi/openapi.yaml b/packages/api/openapi/openapi.yaml index f0881a3..4e9d19d 100644 --- a/packages/api/openapi/openapi.yaml +++ b/packages/api/openapi/openapi.yaml @@ -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: [] diff --git a/packages/api/openapi/paths/approval-policies-id.yaml b/packages/api/openapi/paths/approval-policies-id.yaml new file mode 100644 index 0000000..e0378f8 --- /dev/null +++ b/packages/api/openapi/paths/approval-policies-id.yaml @@ -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 diff --git a/packages/api/openapi/paths/approval-policies.yaml b/packages/api/openapi/paths/approval-policies.yaml new file mode 100644 index 0000000..bac3711 --- /dev/null +++ b/packages/api/openapi/paths/approval-policies.yaml @@ -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 diff --git a/packages/api/openapi/paths/approval-steps-id-decisions.yaml b/packages/api/openapi/paths/approval-steps-id-decisions.yaml new file mode 100644 index 0000000..457a467 --- /dev/null +++ b/packages/api/openapi/paths/approval-steps-id-decisions.yaml @@ -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 diff --git a/packages/api/openapi/paths/inbox.yaml b/packages/api/openapi/paths/inbox.yaml new file mode 100644 index 0000000..b6ae046 --- /dev/null +++ b/packages/api/openapi/paths/inbox.yaml @@ -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 diff --git a/packages/api/openapi/paths/invoices-id-activity-logs.yaml b/packages/api/openapi/paths/invoices-id-activity-logs.yaml new file mode 100644 index 0000000..6ddd237 --- /dev/null +++ b/packages/api/openapi/paths/invoices-id-activity-logs.yaml @@ -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 diff --git a/packages/api/openapi/paths/invoices-id-comments.yaml b/packages/api/openapi/paths/invoices-id-comments.yaml new file mode 100644 index 0000000..e8d4a36 --- /dev/null +++ b/packages/api/openapi/paths/invoices-id-comments.yaml @@ -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 diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 455b8f5..eb647df 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -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.")); diff --git a/packages/api/src/approvals.ts b/packages/api/src/approvals.ts new file mode 100644 index 0000000..509b94d --- /dev/null +++ b/packages/api/src/approvals.ts @@ -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 { + await appendActivity(handle, invoice.id, actorUserId, "Invoice created."); + const policies = (await rowsOf(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 { + const rows = await rowsOf(handle, approvalSteps); + return rows.filter((row) => row.invoiceId === invoiceId && row.status === "pending"); +} diff --git a/packages/api/src/routes/approvals.test.ts b/packages/api/src/routes/approvals.test.ts new file mode 100644 index 0000000..4a50cc3 --- /dev/null +++ b/packages/api/src/routes/approvals.test.ts @@ -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 }); + }); +}); diff --git a/packages/api/src/routes/approvals.ts b/packages/api/src/routes/approvals.ts new file mode 100644 index 0000000..c7f55e3 --- /dev/null +++ b/packages/api/src/routes/approvals.ts @@ -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(); + + 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; +} diff --git a/packages/api/src/routes/invoices.ts b/packages/api/src/routes/invoices.ts index 87bf05b..1528ed9 100644 --- a/packages/api/src/routes/invoices.ts +++ b/packages/api/src/routes/invoices.ts @@ -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(handle, invoices, id); if (!existing) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); const body = await parseJsonBody(c); - const patch: Partial = { updatedAt: new Date() }; + const patch: Partial = { id, updatedAt: new Date() }; if (body.vendorId !== undefined) { const vendorId = asString(body.vendorId); if (!isUuid(vendorId)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid vendorId."); diff --git a/packages/api/src/test/fake-db.ts b/packages/api/src/test/fake-db.ts index b9d10f4..d18380f 100644 --- a/packages/api/src/test/fake-db.ts +++ b/packages/api/src/test/fake-db.ts @@ -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; invoiceLines: Array; documents: Array; - approvalPolicies: Array>; - approvalSteps: Array>; - invoiceComments: Array>; - activityLog: Array>; + approvalPolicies: Array; + approvalSteps: Array; + invoiceComments: Array; + activityLog: Array; }; 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]; diff --git a/redocly.yaml b/redocly.yaml index 6b39d1c..6b2c306 100644 --- a/redocly.yaml +++ b/redocly.yaml @@ -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 diff --git a/src/api/types.ts b/src/api/types.ts index b4e3f46..e5b36b0 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 }> } }; + }; };