From df6ccc7ade8c769ae611c059b2816c9c88302ddd Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:41:48 +0000 Subject: [PATCH 1/7] chore(ci): remove unused Mergify stub (#66) --- .mergify.yml | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .mergify.yml diff --git a/.mergify.yml b/.mergify.yml deleted file mode 100644 index 526045d..0000000 --- a/.mergify.yml +++ /dev/null @@ -1 +0,0 @@ -extends: .github From c6867e6becc4bb678f846a818fd7b8920fc70985 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:51:59 +0000 Subject: [PATCH 2/7] feat(web): expand mock invoice fields and multi-line cells (AP-47) (#16) * feat(web): fix shell chrome, logo home, and live nav badges Refs: AP-42 * feat(web): add invoice grid lines and bold headers Refs: AP-44 * feat(web): support auto row height for multi-line invoice cells Refs: AP-44 * feat(web): expand mock invoice fields for Stampli column catalog Refs: AP-47 * feat(web): add multi-line Stampli invoice cell renderers Refs: AP-47 * fix(web): address review feedback * ci: remove the pr policy caller * fix(web): keep mock catalog dates and updates in sync * fix(web): derive mock action labels from invoice status * fix(ci): drop the unused catalog refresh index * fix(ci): format invoice mock helpers --- e2e/dashboard-grids.spec.ts | 4 +- src/dashboard/column-catalog.test.ts | 48 +++++++ src/dashboard/column-catalog.ts | 77 +++++++++++ src/dashboard/columns.test.ts | 28 ++++ src/dashboard/columns.tsx | 182 ++++++++++++++++++++++--- src/dashboard/invoice-date-format.ts | 56 ++++++++ src/mocks/index.test.ts | 102 ++++++++++++++ src/mocks/invoices.ts | 190 ++++++++++++++++++++++++--- 8 files changed, 650 insertions(+), 37 deletions(-) create mode 100644 src/dashboard/column-catalog.test.ts create mode 100644 src/dashboard/column-catalog.ts create mode 100644 src/dashboard/columns.test.ts create mode 100644 src/dashboard/invoice-date-format.ts diff --git a/e2e/dashboard-grids.spec.ts b/e2e/dashboard-grids.spec.ts index 160476a..29bc24c 100644 --- a/e2e/dashboard-grids.spec.ts +++ b/e2e/dashboard-grids.spec.ts @@ -61,12 +61,12 @@ test.describe("dashboard grids", () => { await editor.fill("2026-09-01"); await editor.press("Enter"); - await expect(payDateCell).toContainText("Sep 1, 2026"); + await expect(payDateCell).toContainText("09/01/2026"); await page.getByTestId("tab-pending").click(); await page.getByTestId("tab-ready").click(); await expect( page.getByTestId("grid-ready").locator('[data-field="payDate"]').nth(1), - ).toContainText("Sep 1, 2026"); + ).toContainText("09/01/2026"); }); }); diff --git a/src/dashboard/column-catalog.test.ts b/src/dashboard/column-catalog.test.ts new file mode 100644 index 0000000..19c69df --- /dev/null +++ b/src/dashboard/column-catalog.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_VISIBLE_COLUMN_KEYS, + INVOICE_ACTION_OPTIONS, + INVOICE_COLUMN_CATALOG, + LOCKED_COLUMN_KEYS, +} from "@/dashboard/column-catalog"; + +describe("INVOICE_COLUMN_CATALOG", () => { + it("locks Stampli Visible defaults and locked columns", () => { + expect(DEFAULT_VISIBLE_COLUMN_KEYS).toEqual([ + "bolt", + "bell", + "invoiceId", + "userStatus", + "vendor", + "lastUpdate", + "invoiceDate", + "dueDate", + "amount", + "tagging", + "fs", + "eye", + ]); + expect(LOCKED_COLUMN_KEYS).toEqual(["bolt", "bell", "invoiceId", "vendor"]); + }); + + it("includes Hidden catalog entries", () => { + const hidden = INVOICE_COLUMN_CATALOG.filter((entry) => !entry.defaultVisible).map( + (entry) => entry.key, + ); + expect(hidden).toEqual([ + "description", + "type", + "location", + "paymentAccount", + "paymentDate", + "processingBegan", + "relatedInvoice", + "rowCount", + ]); + }); + + it("locks the ten Action filter options", () => { + expect(INVOICE_ACTION_OPTIONS).toHaveLength(10); + expect(INVOICE_ACTION_OPTIONS).toContain("Approve invoice"); + }); +}); diff --git a/src/dashboard/column-catalog.ts b/src/dashboard/column-catalog.ts new file mode 100644 index 0000000..4ef3d06 --- /dev/null +++ b/src/dashboard/column-catalog.ts @@ -0,0 +1,77 @@ +/** Locked Stampli HTML column catalog (AP-41 / AP-47). */ + +export type InvoiceColumnKey = + | "bolt" + | "bell" + | "invoiceId" + | "userStatus" + | "vendor" + | "lastUpdate" + | "invoiceDate" + | "dueDate" + | "amount" + | "tagging" + | "fs" + | "eye" + | "description" + | "type" + | "location" + | "paymentAccount" + | "paymentDate" + | "processingBegan" + | "relatedInvoice" + | "rowCount"; + +export type InvoiceColumnCatalogEntry = { + key: InvoiceColumnKey; + label: string; + locked: boolean; + defaultVisible: boolean; +}; + +export const INVOICE_COLUMN_CATALOG: InvoiceColumnCatalogEntry[] = [ + { key: "bolt", label: "Bolt", locked: true, defaultVisible: true }, + { key: "bell", label: "Bell", locked: true, defaultVisible: true }, + { key: "invoiceId", label: "Invoice", locked: true, defaultVisible: true }, + { key: "userStatus", label: "Action", locked: false, defaultVisible: true }, + { key: "vendor", label: "Vendor", locked: true, defaultVisible: true }, + { key: "lastUpdate", label: "Last Update", locked: false, defaultVisible: true }, + { key: "invoiceDate", label: "Invoice Date", locked: false, defaultVisible: true }, + { key: "dueDate", label: "Due Date", locked: false, defaultVisible: true }, + { key: "amount", label: "Σ Sum", locked: false, defaultVisible: true }, + { key: "tagging", label: "Tagging", locked: false, defaultVisible: true }, + { key: "fs", label: "F.S.", locked: false, defaultVisible: true }, + { key: "eye", label: "Eye", locked: false, defaultVisible: true }, + { key: "description", label: "Description", locked: false, defaultVisible: false }, + { key: "type", label: "Invoice Type", locked: false, defaultVisible: false }, + { key: "location", label: "Location", locked: false, defaultVisible: false }, + { key: "paymentAccount", label: "Payment Account", locked: false, defaultVisible: false }, + { key: "paymentDate", label: "Payment Date", locked: false, defaultVisible: false }, + { key: "processingBegan", label: "Processing Began", locked: false, defaultVisible: false }, + { key: "relatedInvoice", label: "Related Invoice", locked: false, defaultVisible: false }, + { key: "rowCount", label: "Row count", locked: false, defaultVisible: false }, +]; + +export const DEFAULT_VISIBLE_COLUMN_KEYS: InvoiceColumnKey[] = INVOICE_COLUMN_CATALOG.filter( + (entry) => entry.defaultVisible, +).map((entry) => entry.key); + +export const LOCKED_COLUMN_KEYS: InvoiceColumnKey[] = INVOICE_COLUMN_CATALOG.filter( + (entry) => entry.locked, +).map((entry) => entry.key); + +/** Locked Action filter options from Stampli HTML (AP-41 / AP-43). */ +export const INVOICE_ACTION_OPTIONS = [ + "A field update request has been submitted", + "A question has been submitted", + "Approve invoice", + "Attach receipt", + "Filed for my attention", + "Inquiry for you", + "Recalled invoice", + "Received response", + "View Comment", + "Waiting for response", +] as const; + +export type InvoiceActionOption = (typeof INVOICE_ACTION_OPTIONS)[number]; diff --git a/src/dashboard/columns.test.ts b/src/dashboard/columns.test.ts new file mode 100644 index 0000000..a0cdeb1 --- /dev/null +++ b/src/dashboard/columns.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { formatRelativeFromIsoDate, formatSlashDate } from "@/dashboard/invoice-date-format"; + +describe("invoice column date helpers (AP-47)", () => { + it("formats ISO dates as MM/DD/YYYY", () => { + expect(formatSlashDate("2026-04-10")).toBe("04/10/2026"); + }); + + it("formats relative ages from a fixed now", () => { + const now = new Date("2026-08-11T12:00:00"); + expect(formatRelativeFromIsoDate("2026-08-11", now)).toBe("today"); + expect(formatRelativeFromIsoDate("2026-08-10", now)).toBe("1 day ago"); + expect(formatRelativeFromIsoDate("2026-02-11", now)).toBe("6 months ago"); + }); + + it("uses calendar days so early hours still treat yesterday as 1 day ago", () => { + const earlyMorning = new Date("2026-08-11T01:15:00"); + expect(formatRelativeFromIsoDate("2026-08-10", earlyMorning)).toBe("1 day ago"); + expect(formatRelativeFromIsoDate("2026-08-11", earlyMorning)).toBe("today"); + }); + + it("formats future invoice and due dates instead of clamping to today", () => { + const now = new Date("2026-08-11T12:00:00"); + expect(formatRelativeFromIsoDate("2026-08-12", now)).toBe("in 1 day"); + expect(formatRelativeFromIsoDate("2026-08-16", now)).toBe("in 5 days"); + expect(formatRelativeFromIsoDate("2026-10-11", now)).toBe("in 2 months"); + }); +}); diff --git a/src/dashboard/columns.tsx b/src/dashboard/columns.tsx index 686468e..3b1ded2 100644 --- a/src/dashboard/columns.tsx +++ b/src/dashboard/columns.tsx @@ -1,35 +1,42 @@ -import { Link as MuiLink } from "@mui/material"; +import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined"; +import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; +import { Box, Link as MuiLink, Stack, Typography } from "@mui/material"; import type { GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import type { InvoiceColumnKey } from "@/dashboard/column-catalog"; +import { formatRelativeFromIsoDate, formatSlashDate } from "@/dashboard/invoice-date-format"; import type { MockInvoice } from "@/mocks"; import { Link } from "@/router"; -const currencyFormatter = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", -}); - -const dateFormatter = new Intl.DateTimeFormat("en-US", { +const dateTimeFormatter = new Intl.DateTimeFormat("en-US", { year: "numeric", - month: "short", - day: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, }); -function formatIsoDate(value: string | null | undefined): string { - if (!value) { +function formatAmountUsd(value: number | null | undefined): string { + if (value == null) { return "—"; } - const parsed = new Date(`${value}T00:00:00`); - if (Number.isNaN(parsed.getTime())) { - return value; - } - return dateFormatter.format(parsed); + return `${value.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} USD`; +} + +function formatIsoDate(value: string | null | undefined): string { + return formatSlashDate(value); } function formatCurrency(value: number | null | undefined): string { if (value == null) { return "—"; } - return currencyFormatter.format(value); + return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(value); } const moneyColumn = (field: keyof MockInvoice, headerName: string): GridColDef => ({ @@ -54,6 +61,22 @@ const dateColumn = ( valueFormatter: (value: string | null | undefined) => formatIsoDate(value), }); +function twoLineCell(primary: string, secondary: string, primaryBold = false) { + return ( + + + {primary} + + + {secondary} + + + ); +} + function invoiceNumberLink(params: GridRenderCellParams) { return ( ) { underline="hover" data-testid={`invoice-link-${params.row.id}`} onClick={(event) => event.stopPropagation()} + sx={{ fontWeight: 700, color: "text.primary" }} > {params.value} @@ -95,17 +119,137 @@ export const invoiceAttentionColumns: GridColDef[] = [ renderCell: invoiceNumberLink, }, { - field: "status", + field: "actionLabel", headerName: "Action", flex: 1.2, minWidth: 140, - valueFormatter: (value: string) => (value === "pending_approval" ? "Approve invoice" : value), }, { field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 }, dateColumn("dueDate", "Due Date"), moneyColumn("amountDue", "Σ Sum"), ]; +const iconColumn = ( + field: "hasBolt" | "hasBell" | "hasEye", + Icon: typeof BoltOutlinedIcon, +): GridColDef => ({ + field, + headerName: "", + width: 56, + sortable: false, + filterable: false, + renderHeader: () => , + renderCell: (params) => (params.value ? : null), +}); + +function dualDateColumn( + field: "invoiceDate" | "dueDate", + headerName: string, +): GridColDef { + return { + field, + headerName, + flex: 1, + minWidth: 120, + renderCell: (params) => + twoLineCell( + formatSlashDate(params.value as string), + formatRelativeFromIsoDate(params.value as string), + ), + }; +} + +/** Full catalog column defs keyed by AP-41 HTML column keys (AP-47). */ +export const catalogColumnDefs: Record> = { + bolt: iconColumn("hasBolt", BoltOutlinedIcon), + bell: iconColumn("hasBell", NotificationsNoneOutlinedIcon), + invoiceId: { + field: "invoiceNumbers", + headerName: "Invoice", + flex: 1, + minWidth: 120, + renderCell: invoiceNumberLink, + }, + userStatus: { field: "actionLabel", headerName: "Action", flex: 1.2, minWidth: 140 }, + vendor: { + field: "vendorName", + headerName: "Vendor", + flex: 1.4, + minWidth: 160, + renderCell: (params) => twoLineCell(params.row.vendorName, params.row.vendorId, true), + }, + lastUpdate: { + field: "lastUpdate", + headerName: "Last Update", + flex: 1.6, + minWidth: 180, + renderCell: (params) => { + const stamp = dateTimeFormatter.format(new Date(params.row.lastUpdateAt)); + return twoLineCell( + params.row.lastUpdateMessage, + `${stamp} ${params.row.lastUpdateActorName}`, + ); + }, + }, + invoiceDate: dualDateColumn("invoiceDate", "Invoice Date"), + dueDate: dualDateColumn("dueDate", "Due Date"), + amount: { + field: "amountDue", + headerName: "Σ Sum", + flex: 1, + minWidth: 120, + type: "number", + renderHeader: () => ( + + Σ Sum + + ), + renderCell: (params) => ( + + {formatAmountUsd(params.row.amountDue)} + + ), + }, + tagging: { field: "tagging", headerName: "Tagging", flex: 0.8, minWidth: 100 }, + fs: { + field: "fs", + headerName: "F.S.", + flex: 0.6, + minWidth: 80, + renderCell: (params) => + params.row.fs ? : null, + }, + eye: iconColumn("hasEye", VisibilityOutlinedIcon), + description: { field: "description", headerName: "Description", flex: 1.4, minWidth: 160 }, + type: { field: "invoiceType", headerName: "Invoice Type", flex: 1, minWidth: 120 }, + location: { field: "location", headerName: "Location", flex: 1, minWidth: 120 }, + paymentAccount: { + field: "paymentAccount", + headerName: "Payment Account", + flex: 1.2, + minWidth: 140, + }, + paymentDate: dateColumn("payDate", "Payment Date"), + processingBegan: dateColumn("processingBegan", "Processing Began"), + relatedInvoice: { + field: "relatedInvoice", + headerName: "Related Invoice", + flex: 1, + minWidth: 120, + valueFormatter: (value: string | null) => value ?? "—", + }, + rowCount: { + field: "rowCount", + headerName: "Row count", + width: 100, + type: "number", + }, +}; + +export function columnsFromCatalogKeys(keys: InvoiceColumnKey[]): GridColDef[] { + return keys.map((key) => catalogColumnDefs[key]); +} + export const readyToPayColumns: GridColDef[] = [ { field: "invoiceNumber", diff --git a/src/dashboard/invoice-date-format.ts b/src/dashboard/invoice-date-format.ts new file mode 100644 index 0000000..f02ade0 --- /dev/null +++ b/src/dashboard/invoice-date-format.ts @@ -0,0 +1,56 @@ +function startOfLocalDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function formatUnit(count: number, unit: "day" | "month" | "year", future: boolean): string { + const noun = count === 1 ? unit : `${unit}s`; + return future ? `in ${count} ${noun}` : `${count} ${noun} ago`; +} + +export function formatSlashDate(value: string | null | undefined): string { + if (!value) { + return "—"; + } + const parsed = new Date(`${value.slice(0, 10)}T00:00:00`); + if (Number.isNaN(parsed.getTime())) { + return value; + } + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(parsed); +} + +export function formatRelativeFromIsoDate( + value: string | null | undefined, + now: Date = new Date(), +): string { + if (!value) { + return ""; + } + const parsed = new Date(`${value.slice(0, 10)}T00:00:00`); + if (Number.isNaN(parsed.getTime())) { + return ""; + } + + const dayMs = 24 * 60 * 60 * 1000; + const days = Math.round( + (startOfLocalDay(now).getTime() - startOfLocalDay(parsed).getTime()) / dayMs, + ); + if (days === 0) { + return "today"; + } + + const future = days < 0; + const absDays = Math.abs(days); + if (absDays < 30) { + return formatUnit(absDays, "day", future); + } + const months = Math.floor(absDays / 30); + if (months < 12) { + return formatUnit(months, "month", future); + } + const years = Math.floor(months / 12); + return formatUnit(years, "year", future); +} diff --git a/src/mocks/index.test.ts b/src/mocks/index.test.ts index f3e8692..0729bd8 100644 --- a/src/mocks/index.test.ts +++ b/src/mocks/index.test.ts @@ -54,6 +54,58 @@ describe("mocks module", () => { expect(getMockInvoices().length).toBeGreaterThanOrEqual(6); }); + it("seeds Stampli catalog fields on every invoice (AP-47)", () => { + const invoices = getMockInvoices(); + expect(invoices.length).toBeGreaterThan(0); + for (const invoice of invoices) { + expect(typeof invoice.hasBolt).toBe("boolean"); + expect(typeof invoice.hasBell).toBe("boolean"); + expect(typeof invoice.hasEye).toBe("boolean"); + expect(invoice.actionLabel.length).toBeGreaterThan(0); + expect(invoice.lastUpdate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(invoice.lastUpdateMessage.length).toBeGreaterThan(0); + expect(invoice.lastUpdateActorName.length).toBeGreaterThan(0); + expect(invoice.lastUpdateAt.length).toBeGreaterThan(0); + expect(invoice.invoiceDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(invoice.invoiceDate).not.toBe(invoice.dueDate); + expect(invoice.processingBegan).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(typeof invoice.tagging).toBe("string"); + expect(typeof invoice.fs).toBe("string"); + expect(typeof invoice.description).toBe("string"); + expect(typeof invoice.invoiceType).toBe("string"); + expect(typeof invoice.location).toBe("string"); + expect(typeof invoice.paymentAccount).toBe("string"); + expect(invoice.relatedInvoice === null || typeof invoice.relatedInvoice === "string").toBe( + true, + ); + expect(invoice.rowCount).toBe(invoice.lines.length); + } + const pending = invoices.filter((invoice) => invoice.status === "pending_approval"); + expect(pending.every((invoice) => invoice.actionLabel === "Approve invoice")).toBe(true); + const nonPending = invoices.filter((invoice) => invoice.status !== "pending_approval"); + expect(nonPending.every((invoice) => invoice.actionLabel !== "Approve invoice")).toBe(true); + expect( + invoices + .filter((invoice) => invoice.status === "approved") + .every((invoice) => invoice.actionLabel === "Approved"), + ).toBe(true); + expect( + invoices + .filter((invoice) => invoice.status === "paid") + .every((invoice) => invoice.actionLabel === "Paid"), + ).toBe(true); + expect( + invoices + .filter((invoice) => invoice.status === "scheduled") + .every((invoice) => invoice.actionLabel === "Scheduled"), + ).toBe(true); + expect( + invoices + .filter((invoice) => invoice.status === "rejected") + .every((invoice) => invoice.actionLabel === "Rejected"), + ).toBe(true); + }); + it("seeds at least two invoices per dashboard tab", () => { const invoices = getMockInvoices(); const pending = invoices.filter((invoice) => invoice.status === "pending_approval"); @@ -70,6 +122,8 @@ describe("mocks module", () => { const updated = updateMockInvoicePayDate("inv-1", "2026-08-18"); expect(updated.payDate).toBe("2026-08-18"); + expect(updated.lastUpdateMessage).toBe("Pay date updated"); + expect(updated.lastUpdateAt).not.toBe(before?.lastUpdateAt); const after = getMockInvoices().find((invoice) => invoice.id === "inv-1"); expect(after?.payDate).toBe("2026-08-18"); @@ -94,6 +148,7 @@ describe("mocks module", () => { const updated = updateMockInvoiceHeader("inv-2", { memo: "Updated memo", amountDue: 4700 }); expect(updated.memo).toBe("Updated memo"); expect(updated.amountDue).toBe(4700); + expect(updated.description).toBe("Updated memo"); const withLines = updateMockInvoiceLines("inv-2", [ { @@ -106,7 +161,9 @@ describe("mocks module", () => { ]); expect(withLines.lines).toHaveLength(1); expect(withLines.lines[0].description).toBe("Updated line"); + expect(withLines.rowCount).toBe(1); expect(getMockInvoice("inv-2")?.lines[0].description).toBe("Updated line"); + expect(getMockInvoice("inv-2")?.rowCount).toBe(1); }); it("adds comments and transitions status with activity", () => { @@ -114,6 +171,7 @@ describe("mocks module", () => { authorUserId: "user-1", authorName: "Alex Rivera", body: "Need another look", + createdAt: "2026-08-11T09:00:00.000Z", }); expect(withComment.comments.some((comment) => comment.body === "Need another look")).toBe(true); @@ -121,13 +179,57 @@ describe("mocks module", () => { actorUserId: "user-2", actorName: "Jordan Lee", message: "Approved", + createdAt: "2026-08-12T09:00:00.000Z", }); expect(approved.status).toBe("approved"); expect(approved.activity.some((entry) => entry.message === "Approved")).toBe(true); + expect(approved.lastUpdateMessage).toBe("Approved"); + expect(approved.lastUpdateActorName).toBe("Jordan Lee"); + expect(approved.lastUpdateAt).toBe("2026-08-12T09:00:00.000Z"); + expect(approved.lastUpdate).toBe("2026-08-12"); + expect(approved.actionLabel).not.toBe("Approve invoice"); expect(getMockPendingApprovals().some((item) => item.invoiceId === "inv-2")).toBe(false); expect(getMockApprovals().find((item) => item.invoiceId === "inv-2")?.status).toBe("approved"); }); + it("refreshes last update from a comment and payment account from the method", () => { + const commented = addMockInvoiceComment("inv-1", { + authorUserId: "user-1", + authorName: "Alex Rivera", + body: "Checked the total", + createdAt: "2026-09-01T15:00:00.000Z", + }); + expect(commented.lastUpdateAt).toBe("2026-09-01T15:00:00.000Z"); + expect(commented.lastUpdate).toBe("2026-09-01"); + expect(commented.lastUpdateMessage).toBe("Checked the total"); + expect(commented.lastUpdateActorName).toBe("Alex Rivera"); + + const method = updateMockInvoiceHeader("inv-1", { paymentMethod: "ach" }); + expect(method.paymentAccount).toBe("Operating ACH"); + expect(getMockInvoice("inv-1")?.paymentAccount).toBe("Operating ACH"); + }); + + it("refreshes description and rowCount when header or lines change", () => { + const before = getMockInvoice("inv-1"); + expect(before?.rowCount).toBe(2); + expect(before?.description).toBe("Office supplies restock"); + + const header = updateMockInvoiceHeader("inv-1", { memo: "Revised supplies memo" }); + expect(header.description).toBe("Revised supplies memo"); + + const lines = updateMockInvoiceLines("inv-1", [ + { + id: "inv-1-line-1", + description: "Only line", + amount: 100, + glAccountId: "gl-1", + departmentId: "dept-4", + }, + ]); + expect(lines.rowCount).toBe(1); + expect(getMockInvoice("inv-1")?.rowCount).toBe(1); + }); + it("returns undefined for unknown invoice ids", () => { expect(getMockInvoice("missing-invoice")).toBeUndefined(); }); diff --git a/src/mocks/invoices.ts b/src/mocks/invoices.ts index c324ad7..8f7b4b5 100644 --- a/src/mocks/invoices.ts +++ b/src/mocks/invoices.ts @@ -45,6 +45,24 @@ export type MockInvoice = { lines: MockInvoiceLine[]; comments: MockInvoiceComment[]; activity: MockInvoiceActivity[]; + hasBolt: boolean; + hasBell: boolean; + hasEye: boolean; + actionLabel: string; + lastUpdate: string; + lastUpdateMessage: string; + lastUpdateActorName: string; + lastUpdateAt: string; + invoiceDate: string; + processingBegan: string; + tagging: string; + fs: string; + description: string; + invoiceType: string; + location: string; + paymentAccount: string; + relatedInvoice: string | null; + rowCount: number; }; export type MockInvoiceHeaderPatch = Partial< @@ -63,6 +81,116 @@ export type MockInvoiceHeaderPatch = Partial< const SAMPLE_DOCUMENT_URL = "/fixtures/sample-invoice.pdf"; +type MockInvoiceCore = Omit< + MockInvoice, + | "hasBolt" + | "hasBell" + | "hasEye" + | "actionLabel" + | "lastUpdate" + | "lastUpdateMessage" + | "lastUpdateActorName" + | "lastUpdateAt" + | "invoiceDate" + | "processingBegan" + | "tagging" + | "fs" + | "description" + | "invoiceType" + | "location" + | "paymentAccount" + | "relatedInvoice" + | "rowCount" +>; + +const ACTION_LABEL_BY_STATUS: Record = { + pending_approval: "Approve invoice", + approved: "Approved", + scheduled: "Scheduled", + paid: "Paid", + rejected: "Rejected", +}; + +function actionLabelForStatus(status: MockInvoiceStatus): string { + return ACTION_LABEL_BY_STATUS[status]; +} + +function derivedActivityFields(invoice: Pick) { + const events = [ + ...invoice.activity.map((entry) => ({ + at: entry.createdAt, + message: entry.message, + actor: entry.actorName, + })), + ...invoice.comments.map((comment) => ({ + at: comment.createdAt, + message: comment.body, + actor: comment.authorName, + })), + ]; + const latest = events.reduce<(typeof events)[number] | undefined>((current, event) => { + if (!current || event.at > current.at) { + return event; + } + return current; + }, undefined); + const lastUpdateAt = latest?.at ?? `${invoice.dueDate}T12:00:00.000Z`; + return { + lastUpdate: lastUpdateAt.slice(0, 10), + lastUpdateMessage: latest?.message ?? "Invoice updated", + lastUpdateActorName: latest?.actor ?? "System", + lastUpdateAt, + }; +} + +function paymentAccountFor(paymentMethod: MockInvoice["paymentMethod"]): string { + return paymentMethod === "ach" ? "Operating ACH" : "Operating Check"; +} + +/** Invoice date is independent of the due date. Offset varies per seed. */ +function invoiceDateBeforeDue(dueDate: string, index: number): string { + const [year, month, day] = dueDate.split("-").map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + date.setUTCDate(date.getUTCDate() - (14 + index)); + return date.toISOString().slice(0, 10); +} + +/** Keep catalog-derived fields in sync after mock mutations. */ +function withRefreshedCatalogFields(invoice: MockInvoice): MockInvoice { + return { + ...invoice, + actionLabel: actionLabelForStatus(invoice.status), + ...derivedActivityFields(invoice), + description: invoice.memo, + paymentAccount: paymentAccountFor(invoice.paymentMethod), + rowCount: invoice.lines.length, + }; +} + +function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice { + return withRefreshedCatalogFields({ + ...core, + hasBolt: index % 3 === 0, + hasBell: index % 2 === 0, + hasEye: true, + actionLabel: actionLabelForStatus(core.status), + lastUpdate: "", + lastUpdateMessage: "", + lastUpdateActorName: "", + lastUpdateAt: "", + invoiceDate: invoiceDateBeforeDue(core.dueDate, index), + processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate, + tagging: index % 2 === 0 ? "Ops" : "", + fs: index % 2 === 0 ? "synced" : "", + description: core.memo, + invoiceType: "Standard", + location: "Ronkonkoma", + paymentAccount: paymentAccountFor(core.paymentMethod), + relatedInvoice: null, + rowCount: core.lines.length, + }); +} + function cloneInvoice(invoice: MockInvoice): MockInvoice { return { ...invoice, @@ -72,7 +200,7 @@ function cloneInvoice(invoice: MockInvoice): MockInvoice { }; } -const seedInvoices: MockInvoice[] = [ +const seedInvoiceCores: MockInvoiceCore[] = [ { id: "inv-1", invoiceNumber: "INV-1001", @@ -414,6 +542,10 @@ const seedInvoices: MockInvoice[] = [ }, ]; +const seedInvoices: MockInvoice[] = seedInvoiceCores.map((core, index) => + enrichInvoice(core, index), +); + /** Mutable in-memory invoice store for mock POC interactions. */ let invoiceStore: MockInvoice[] = seedInvoices.map((invoice) => cloneInvoice(invoice)); @@ -443,7 +575,24 @@ export function updateMockInvoicePayDate(id: string, payDate: string | null): Mo throw new Error(`Mock invoice not found: ${id}`); } - return replaceInvoice(index, { ...invoiceStore[index], payDate }); + const current = invoiceStore[index]; + return replaceInvoice( + index, + withRefreshedCatalogFields({ + ...current, + payDate, + activity: [ + ...current.activity, + { + id: `activity-pay-date-${Date.now()}`, + actorUserId: "user-1", + actorName: "System", + message: "Pay date updated", + createdAt: new Date().toISOString(), + }, + ], + }), + ); } export function updateMockInvoiceHeader(id: string, patch: MockInvoiceHeaderPatch): MockInvoice { @@ -452,7 +601,7 @@ export function updateMockInvoiceHeader(id: string, patch: MockInvoiceHeaderPatc throw new Error(`Mock invoice not found: ${id}`); } - return replaceInvoice(index, { ...invoiceStore[index], ...patch }); + return replaceInvoice(index, withRefreshedCatalogFields({ ...invoiceStore[index], ...patch })); } export function updateMockInvoiceLines(id: string, lines: MockInvoiceLine[]): MockInvoice { @@ -461,10 +610,13 @@ export function updateMockInvoiceLines(id: string, lines: MockInvoiceLine[]): Mo throw new Error(`Mock invoice not found: ${id}`); } - return replaceInvoice(index, { - ...invoiceStore[index], - lines: lines.map((line) => ({ ...line })), - }); + return replaceInvoice( + index, + withRefreshedCatalogFields({ + ...invoiceStore[index], + lines: lines.map((line) => ({ ...line })), + }), + ); } export function addMockInvoiceComment( @@ -488,10 +640,13 @@ export function addMockInvoiceComment( createdAt: comment.createdAt ?? new Date().toISOString(), }; - return replaceInvoice(index, { - ...current, - comments: [...current.comments, nextComment], - }); + return replaceInvoice( + index, + withRefreshedCatalogFields({ + ...current, + comments: [...current.comments, nextComment], + }), + ); } export function transitionMockInvoiceStatus( @@ -516,11 +671,14 @@ export function transitionMockInvoiceStatus( createdAt: activity.createdAt ?? new Date().toISOString(), }; - const updated = replaceInvoice(index, { - ...current, - status, - activity: [...current.activity, nextActivity], - }); + const updated = replaceInvoice( + index, + withRefreshedCatalogFields({ + ...current, + status, + activity: [...current.activity, nextActivity], + }), + ); if (status === "approved" || status === "rejected") { syncMockApprovalsForInvoice(id, status); From fad8461274490640b08e0784f858f05ac9635559 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:13:00 +0000 Subject: [PATCH 3/7] feat(web): action/vendor filters, toolbar layout, and tabs (AP-43) (#17) * feat(web): fix shell chrome, logo home, and live nav badges Refs: AP-42 * feat(web): add invoice grid lines and bold headers Refs: AP-44 * feat(web): support auto row height for multi-line invoice cells Refs: AP-44 * feat(web): expand mock invoice fields for Stampli column catalog Refs: AP-47 * feat(web): add Action and Vendor filters to invoice processing Refs: AP-43 * feat(web): add multi-line Stampli invoice cell renderers Refs: AP-47 * feat(web): align filter toolbar layout, field shapes, and tabs Refs: AP-43 * fix(web): address review feedback * fix(web): address review feedback * ci: remove the pr policy caller * fix(web): keep mock catalog dates and updates in sync * fix(web): render the attention banner as text * fix(web): make the attention banner non-interactive copy * fix(web): derive mock action labels from invoice status * fix(web): restore link-style attention banner copy * fix(ci): drop the unused catalog refresh index * fix(ci): format invoice mock helpers * fix(web): count filtered rows on invoice tabs * fix(web): match the filter toolbar to MUI 9 --- e2e/invoice-filters.spec.ts | 24 ++++ src/dashboard/DashboardGrids.tsx | 184 +++++++++++++++++-------- src/dashboard/InvoiceFilterToolbar.tsx | 117 ++++++++++++++++ src/dashboard/invoice-filters.ts | 17 +++ src/dashboard/invoice-tabs.tsx | 42 ++++++ 5 files changed, 328 insertions(+), 56 deletions(-) create mode 100644 e2e/invoice-filters.spec.ts create mode 100644 src/dashboard/InvoiceFilterToolbar.tsx create mode 100644 src/dashboard/invoice-filters.ts create mode 100644 src/dashboard/invoice-tabs.tsx diff --git a/e2e/invoice-filters.spec.ts b/e2e/invoice-filters.spec.ts new file mode 100644 index 0000000..2f340d3 --- /dev/null +++ b/e2e/invoice-filters.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Invoice Processing filters (AP-43)", () => { + test("Action and Vendor filters reduce grid rows and badge counts", async ({ page }) => { + await page.goto("/"); + const grid = page.getByTestId("grid-pending"); + await expect(grid.locator(".MuiDataGrid-row").first()).toBeVisible(); + const before = await grid.locator(".MuiDataGrid-row").count(); + expect(before).toBeGreaterThan(0); + + await expect(page.getByTestId("action-filter").getByPlaceholder("Search")).toBeVisible(); + await page.getByTestId("action-filter").click(); + await page.getByRole("option", { name: "Approve invoice" }).click(); + await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toHaveText("1"); + + await page.getByTestId("vendor-filter").fill("Harbor"); + await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toHaveText("2"); + await expect(grid.locator(".MuiDataGrid-row")).toHaveCount(1); + await expect(page.getByTestId("tab-pending").locator("span").last()).toHaveText("1"); + await expect(page.getByTestId("attention-banner")).toContainText( + "1 invoices require approval. Show invoices ready to approve.", + ); + }); +}); diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx index 4b98833..9d767fa 100644 --- a/src/dashboard/DashboardGrids.tsx +++ b/src/dashboard/DashboardGrids.tsx @@ -1,16 +1,7 @@ import { useMemo, useState } from "react"; -import { - Alert, - Box, - Button, - Chip, - CircularProgress, - Stack, - Tab, - Tabs, - Typography, -} from "@mui/material"; +import { Alert, Box, CircularProgress, Stack, Tab, Tabs, Typography } from "@mui/material"; import type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid"; +import { InvoiceFilterToolbar } from "@/dashboard/InvoiceFilterToolbar"; import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton"; import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid"; import { @@ -18,6 +9,8 @@ import { invoiceAttentionColumns, readyToPayColumns, } from "@/dashboard/columns"; +import { EMPTY_INVOICE_FILTERS, type InvoiceFilterState } from "@/dashboard/invoice-filters"; +import { InvoiceTabLabel } from "@/dashboard/invoice-tabs"; import { countSelectedRows } from "@/dashboard/selection"; import { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices"; import { areMocksEnabled, type MockInvoice } from "@/mocks"; @@ -30,6 +23,29 @@ const emptySelection: GridRowSelectionModel = { ids: new Set(), }; +const emptyFilters = EMPTY_INVOICE_FILTERS; + +const invoiceTabsSx = { + minHeight: 44, + "& .MuiTab-root": { + bgcolor: "rgba(47,128,237,0.12)", + mr: 0.5, + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + color: "text.primary", + textTransform: "none", + }, + "& .Mui-selected": { + bgcolor: "#fff !important", + color: "text.primary !important", + }, + "& .MuiTabs-indicator": { + height: 3, + borderRadius: 1, + backgroundColor: "#7B61FF", + }, +} as const; + const rowClickSx = { border: 1, borderColor: "divider", @@ -38,10 +54,26 @@ const rowClickSx = { borderRadius: 1, } as const; +function applyInvoiceFilters(rows: MockInvoice[], filters: InvoiceFilterState): MockInvoice[] { + const vendorQuery = filters.vendorQuery.trim().toLowerCase(); + return rows.filter((invoice) => { + if (filters.action && invoice.actionLabel !== filters.action) { + return false; + } + if (vendorQuery && !invoice.vendorName.toLowerCase().includes(vendorQuery)) { + return false; + } + return true; + }); +} + export function DashboardGrids() { const navigate = useNavigate(); const [tab, setTab] = useState("attention"); const [selection, setSelection] = useState(emptySelection); + const [filters, setFilters] = useState(emptyFilters); + const [, setColumnsDrawerOpen] = useState(false); + const [, setFilterDrawerOpen] = useState(false); const { data: invoices = [], isLoading, isError, error } = useInvoices(); const updatePayDate = useUpdateInvoicePayDate(); @@ -69,6 +101,21 @@ export function DashboardGrids() { ); }, [invoices]); + const filteredAttention = useMemo( + () => applyInvoiceFilters(attentionRows, filters), + [attentionRows, filters], + ); + const filteredAll = useMemo(() => applyInvoiceFilters(invoices, filters), [invoices, filters]); + const filteredApproved = useMemo( + () => applyInvoiceFilters(approvedRows, filters), + [approvedRows, filters], + ); + const filteredRejected = useMemo( + () => applyInvoiceFilters(rejectedRows, filters), + [rejectedRows, filters], + ); + const filteredLate = useMemo(() => applyInvoiceFilters(lateRows, filters), [lateRows, filters]); + const selectedCount = countSelectedRows( selection, approvedRows.map((invoice) => invoice.id), @@ -109,70 +156,95 @@ export function DashboardGrids() { }} variant="scrollable" aria-label="Invoice Processing tabs" - sx={{ - minHeight: 44, - "& .MuiTab-root": { - bgcolor: "rgba(47,128,237,0.12)", - mr: 0.5, - borderTopLeftRadius: 6, - borderTopRightRadius: 6, - color: "text.primary", - }, - "& .Mui-selected": { - bgcolor: "#fff !important", - color: "text.primary !important", - }, - "& .MuiTabs-indicator": { display: "none" }, - }} + sx={invoiceTabsSx} > + } value="attention" data-testid="tab-pending" /> - + } + value="all" + data-testid="tab-all" + /> + + } value="approved" data-testid="tab-ready" /> + } value="rejected" data-testid="tab-rejected" /> - + + } + value="late" + data-testid="tab-late" + /> - {attentionRows.length > 0 && tab === "attention" ? ( - - {attentionRows.length} invoices require approval. Show Invoices ready to approve. - + {filteredAttention.length > 0 && tab === "attention" ? ( + + {filteredAttention.length} invoices require approval.{" "} + + Show invoices ready to approve. + + ) : null} - - - - - - {tab === "approved" ? : null} - + setFilterDrawerOpen(true)} + onOpenColumnsDrawer={() => setColumnsDrawerOpen(true)} + trailingActions={ + tab === "approved" ? : null + } + /> {tab === "attention" && ( void; + onOpenFilterDrawer: () => void; + onOpenColumnsDrawer: () => void; + trailingActions?: ReactElement | null; +}; + +const fieldSx = { + minWidth: 180, + "& .MuiOutlinedInput-root": { + borderRadius: "6px", + backgroundColor: "background.paper", + }, +} as const; + +export function InvoiceFilterToolbar({ + filters, + onFiltersChange, + onOpenFilterDrawer, + onOpenColumnsDrawer, + trailingActions = null, +}: InvoiceFilterToolbarProps): ReactElement { + const activeCount = countActiveFilters(filters); + + return ( + + + + onFiltersChange({ ...filters, action: value })} + renderInput={(params) => } + /> + + onFiltersChange({ ...filters, vendorQuery: event.target.value })} + sx={fieldSx} + slotProps={{ + htmlInput: { "data-testid": "vendor-filter", "aria-label": "Vendor" }, + input: { + endAdornment: filters.vendorQuery ? ( + + + + ) : undefined, + }, + }} + /> + + + + + + + + + + + + + + + + + {trailingActions} + + + ); +} diff --git a/src/dashboard/invoice-filters.ts b/src/dashboard/invoice-filters.ts new file mode 100644 index 0000000..5504bcc --- /dev/null +++ b/src/dashboard/invoice-filters.ts @@ -0,0 +1,17 @@ +export type InvoiceFilterState = { + action: string | null; + vendorQuery: string; +}; + +export function countActiveFilters(filters: InvoiceFilterState): number { + let count = 0; + if (filters.action) { + count += 1; + } + if (filters.vendorQuery.trim()) { + count += 1; + } + return count; +} + +export const EMPTY_INVOICE_FILTERS: InvoiceFilterState = { action: null, vendorQuery: "" }; diff --git a/src/dashboard/invoice-tabs.tsx b/src/dashboard/invoice-tabs.tsx new file mode 100644 index 0000000..605ba29 --- /dev/null +++ b/src/dashboard/invoice-tabs.tsx @@ -0,0 +1,42 @@ +import ErrorOutlinedIcon from "@mui/icons-material/ErrorOutlined"; +import { Box, Stack } from "@mui/material"; +import type { ReactElement } from "react"; + +export function InvoiceTabLabel({ + title, + count, + active = false, + urgency = false, +}: { + title: string; + count: number; + active?: boolean; + urgency?: boolean; +}): ReactElement { + return ( + + {title} + {urgency ? ( + + ) : null} + + {count} + + + ); +} From ee2f7a9902446f33e0042a2b18fbde42c312af53 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:28:05 +0000 Subject: [PATCH 4/7] feat(web): add Columns slide-over with Stampli HTML defaults (AP-45) (#18) * feat(web): fix shell chrome, logo home, and live nav badges Refs: AP-42 * feat(web): add invoice grid lines and bold headers Refs: AP-44 * feat(web): support auto row height for multi-line invoice cells Refs: AP-44 * feat(web): expand mock invoice fields for Stampli column catalog Refs: AP-47 * feat(web): add Action and Vendor filters to invoice processing Refs: AP-43 * feat(web): add multi-line Stampli invoice cell renderers Refs: AP-47 * feat(web): align filter toolbar layout, field shapes, and tabs Refs: AP-43 * feat(web): add Columns slide-over with Stampli HTML defaults Refs: AP-45 * fix(web): address review feedback * fix(web): address review feedback * fix(web): address review feedback * ci: remove the pr policy caller * fix(web): keep mock catalog dates and updates in sync * fix(web): render the attention banner as text * fix(web): keep pay date editable on the approved grid * fix(web): make the attention banner non-interactive copy * fix(web): derive mock action labels from invoice status * fix(web): restore link-style attention banner copy * fix(ci): drop the unused catalog refresh index * fix(web): assert the vendor column by its grid field * fix(ci): format invoice mock helpers * fix(web): hide Payment Date when the drawer omits it * fix(web): count filtered rows on invoice tabs * fix(web): close the columns drawer before editing pay date --- e2e/dashboard-grids.spec.ts | 17 +++ e2e/invoice-columns.spec.ts | 64 +++++++++ package-lock.json | 56 ++++++++ package.json | 3 + src/dashboard/ColumnsDrawer.tsx | 207 +++++++++++++++++++++++++++++ src/dashboard/DashboardGrids.tsx | 49 +++++-- src/dashboard/approved-columns.ts | 9 ++ src/dashboard/column-prefs.test.ts | 42 ++++++ src/dashboard/column-prefs.ts | 66 +++++++++ src/dashboard/columns.test.ts | 20 +++ src/dashboard/columns.tsx | 6 + 11 files changed, 529 insertions(+), 10 deletions(-) create mode 100644 e2e/invoice-columns.spec.ts create mode 100644 src/dashboard/ColumnsDrawer.tsx create mode 100644 src/dashboard/approved-columns.ts create mode 100644 src/dashboard/column-prefs.test.ts create mode 100644 src/dashboard/column-prefs.ts diff --git a/e2e/dashboard-grids.spec.ts b/e2e/dashboard-grids.spec.ts index 29bc24c..7089c70 100644 --- a/e2e/dashboard-grids.spec.ts +++ b/e2e/dashboard-grids.spec.ts @@ -53,6 +53,23 @@ test.describe("dashboard grids", () => { /Pay run stub: [2-9]\d* invoice\(s\) selected/, ); + await page.getByTestId("columns-toggle").click(); + const drawer = page.getByTestId("columns-drawer"); + await drawer.getByTestId("columns-hidden-search").fill("Payment Date"); + await drawer + .getByTestId("columns-hidden-paymentDate") + .locator('input[type="checkbox"]') + .evaluate((el: HTMLInputElement) => { + el.click(); + }); + await expect( + readyGrid.locator('.MuiDataGrid-columnHeader[data-field="payDate"]'), + ).toBeVisible(); + await page.getByRole("button", { name: "Close columns" }).evaluate((el: HTMLElement) => { + el.click(); + }); + await expect(drawer).toBeHidden(); + const payDateCell = readyGrid.locator('[data-field="payDate"]').nth(1); await payDateCell.dblclick(); const editor = readyGrid diff --git a/e2e/invoice-columns.spec.ts b/e2e/invoice-columns.spec.ts new file mode 100644 index 0000000..e2853cb --- /dev/null +++ b/e2e/invoice-columns.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Invoice Processing columns drawer (AP-45)", () => { + test("Columns drawer can hide Tagging and Reset restores it", async ({ page }) => { + await page.goto("/"); + await expect( + page.getByTestId("grid-pending").locator('.MuiDataGrid-columnHeader[data-field="tagging"]'), + ).toBeVisible(); + + await page.getByTestId("columns-toggle").click(); + const drawer = page.getByTestId("columns-drawer"); + await expect(drawer).toBeVisible(); + + const taggingRow = drawer.getByTestId("columns-visible-tagging"); + await taggingRow.scrollIntoViewIfNeeded(); + await taggingRow.locator('input[type="checkbox"]').evaluate((el: HTMLInputElement) => { + el.click(); + }); + await expect( + page.getByTestId("grid-pending").locator('.MuiDataGrid-columnHeader[data-field="tagging"]'), + ).toHaveCount(0); + + await drawer.getByTestId("columns-selector-reset-button").evaluate((el: HTMLElement) => { + el.click(); + }); + await expect( + page.getByTestId("grid-pending").locator('.MuiDataGrid-columnHeader[data-field="tagging"]'), + ).toBeVisible(); + }); + + test("locked Vendor column stays visible and Reset clears the hidden search", async ({ + page, + }) => { + await page.goto("/"); + await page.getByTestId("columns-toggle").click(); + const drawer = page.getByTestId("columns-drawer"); + const vendorBox = drawer + .getByTestId("columns-visible-vendor") + .locator('input[type="checkbox"]'); + await expect(vendorBox).toBeDisabled(); + await vendorBox.evaluate((el: HTMLInputElement) => { + el.disabled = false; + el.click(); + }); + await expect( + page + .getByTestId("grid-pending") + .locator('.MuiDataGrid-columnHeader[data-field="vendorName"]'), + ).toBeVisible(); + await page.getByTestId("tab-ready").evaluate((el: HTMLElement) => { + el.click(); + }); + await expect( + page.getByTestId("grid-ready").locator('.MuiDataGrid-columnHeader[data-field="vendorName"]'), + ).toBeVisible(); + + const search = drawer.getByTestId("columns-hidden-search"); + await search.fill("no-such-column"); + await drawer.getByTestId("columns-selector-reset-button").evaluate((el: HTMLElement) => { + el.click(); + }); + await expect(search).toHaveValue(""); + }); +}); diff --git a/package-lock.json b/package-lock.json index d100c79..7fa5aaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "packages/*" ], "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@fontsource/ibm-plex-sans": "^5.3.0", @@ -842,6 +845,59 @@ "node": ">=20.19.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", diff --git a/package.json b/package.json index 440cfe0..7477812 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,9 @@ }, "packageManager": "npm@12.0.2", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@fontsource/ibm-plex-sans": "^5.3.0", diff --git a/src/dashboard/ColumnsDrawer.tsx b/src/dashboard/ColumnsDrawer.tsx new file mode 100644 index 0000000..36b6d46 --- /dev/null +++ b/src/dashboard/ColumnsDrawer.tsx @@ -0,0 +1,207 @@ +import { + DndContext, + PointerSensor, + closestCenter, + type DragEndEvent, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import CloseIcon from "@mui/icons-material/Close"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import { + Box, + Button, + Checkbox, + Drawer, + FormControlLabel, + IconButton, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { CSSProperties, ReactElement } from "react"; +import { useMemo, useState } from "react"; +import type { InvoiceColumnKey } from "@/dashboard/column-catalog"; +import { + catalogLabel, + hiddenColumnKeys, + isColumnLocked, + type ColumnPrefs, +} from "@/dashboard/column-prefs"; + +type ColumnsDrawerProps = { + open: boolean; + onClose: () => void; + prefs: ColumnPrefs; + onPrefsChange: (next: ColumnPrefs) => void; + onReset: () => void; +}; + +function SortableVisibleRow({ + columnKey, + onToggle, +}: { + columnKey: InvoiceColumnKey; + onToggle: (key: InvoiceColumnKey, checked: boolean) => void; +}): ReactElement { + const locked = isColumnLocked(columnKey); + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ + id: columnKey, + }); + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + display: "flex", + alignItems: "center", + gap: 8, + }; + + return ( + + + + + onToggle(columnKey, event.target.checked)} + /> + } + label={catalogLabel(columnKey)} + /> + + ); +} + +export function ColumnsDrawer({ + open, + onClose, + prefs, + onPrefsChange, + onReset, +}: ColumnsDrawerProps): ReactElement { + const [hiddenQuery, setHiddenQuery] = useState(""); + const [viewAllHidden, setViewAllHidden] = useState(false); + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); + + const hidden = useMemo(() => hiddenColumnKeys(prefs.visibleOrder), [prefs.visibleOrder]); + const filteredHidden = useMemo(() => { + const q = hiddenQuery.trim().toLowerCase(); + const list = q ? hidden.filter((key) => catalogLabel(key).toLowerCase().includes(q)) : hidden; + if (viewAllHidden || q) { + return list; + } + return list.slice(0, 5); + }, [hidden, hiddenQuery, viewAllHidden]); + + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + const oldIndex = prefs.visibleOrder.indexOf(active.id as InvoiceColumnKey); + const newIndex = prefs.visibleOrder.indexOf(over.id as InvoiceColumnKey); + if (oldIndex < 0 || newIndex < 0) { + return; + } + onPrefsChange({ visibleOrder: arrayMove(prefs.visibleOrder, oldIndex, newIndex) }); + }; + + const onToggle = (key: InvoiceColumnKey, checked: boolean) => { + if (isColumnLocked(key)) { + return; + } + if (checked) { + if (prefs.visibleOrder.includes(key)) { + return; + } + onPrefsChange({ visibleOrder: [...prefs.visibleOrder, key] }); + return; + } + onPrefsChange({ visibleOrder: prefs.visibleOrder.filter((item) => item !== key) }); + }; + + return ( + + + + Columns + + + + + + + + + + + Visible + + + + + {prefs.visibleOrder.map((key) => ( + + ))} + + + + + + Hidden + + setHiddenQuery(event.target.value)} + sx={{ mb: 1 }} + slotProps={{ htmlInput: { "data-testid": "columns-hidden-search" } }} + /> + + {filteredHidden.map((key) => ( + onToggle(key, event.target.checked)} /> + } + label={catalogLabel(key)} + /> + ))} + + {!viewAllHidden && !hiddenQuery && hidden.length > 5 ? ( + + ) : null} + + ); +} diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx index 9d767fa..ab1f03d 100644 --- a/src/dashboard/DashboardGrids.tsx +++ b/src/dashboard/DashboardGrids.tsx @@ -1,14 +1,17 @@ import { useMemo, useState } from "react"; import { Alert, Box, CircularProgress, Stack, Tab, Tabs, Typography } from "@mui/material"; import type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid"; +import { ColumnsDrawer } from "@/dashboard/ColumnsDrawer"; import { InvoiceFilterToolbar } from "@/dashboard/InvoiceFilterToolbar"; import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton"; import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid"; +import { columnsForApprovedGrid, columnsFromCatalogKeys } from "@/dashboard/columns"; import { - allInvoicesColumns, - invoiceAttentionColumns, - readyToPayColumns, -} from "@/dashboard/columns"; + loadColumnPrefs, + resetColumnPrefs, + saveColumnPrefs, + type ColumnPrefs, +} from "@/dashboard/column-prefs"; import { EMPTY_INVOICE_FILTERS, type InvoiceFilterState } from "@/dashboard/invoice-filters"; import { InvoiceTabLabel } from "@/dashboard/invoice-tabs"; import { countSelectedRows } from "@/dashboard/selection"; @@ -72,11 +75,21 @@ export function DashboardGrids() { const [tab, setTab] = useState("attention"); const [selection, setSelection] = useState(emptySelection); const [filters, setFilters] = useState(emptyFilters); - const [, setColumnsDrawerOpen] = useState(false); + const [columnsDrawerOpen, setColumnsDrawerOpen] = useState(false); const [, setFilterDrawerOpen] = useState(false); + const [columnPrefs, setColumnPrefs] = useState(() => loadColumnPrefs()); const { data: invoices = [], isLoading, isError, error } = useInvoices(); const updatePayDate = useUpdateInvoicePayDate(); + const catalogColumns = useMemo( + () => columnsFromCatalogKeys(columnPrefs.visibleOrder), + [columnPrefs.visibleOrder], + ); + const approvedColumns = useMemo( + () => columnsForApprovedGrid(columnPrefs.visibleOrder), + [columnPrefs.visibleOrder], + ); + const openInvoice = (params: GridRowParams) => { navigate("/invoices/:id", { params: { id: params.row.id } }); }; @@ -129,6 +142,11 @@ export function DashboardGrids() { return updatePayDate.mutateAsync({ id: newRow.id, payDate: nextPayDate }); }; + const updateColumnPrefs = (next: ColumnPrefs) => { + setColumnPrefs(next); + saveColumnPrefs(next); + }; + if (isLoading) { return ( @@ -245,7 +263,7 @@ export function DashboardGrids() { @@ -255,7 +273,7 @@ export function DashboardGrids() { @@ -265,7 +283,7 @@ export function DashboardGrids() { setSelection(model)} @@ -281,7 +299,7 @@ export function DashboardGrids() { @@ -291,7 +309,7 @@ export function DashboardGrids() { @@ -301,6 +319,17 @@ export function DashboardGrids() { Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {invoices.length} invoices + + setColumnsDrawerOpen(false)} + prefs={columnPrefs} + onPrefsChange={updateColumnPrefs} + onReset={() => { + const next = resetColumnPrefs(); + setColumnPrefs(next); + }} + /> ); } diff --git a/src/dashboard/approved-columns.ts b/src/dashboard/approved-columns.ts new file mode 100644 index 0000000..b94e671 --- /dev/null +++ b/src/dashboard/approved-columns.ts @@ -0,0 +1,9 @@ +import type { GridColDef } from "@mui/x-data-grid"; +import type { MockInvoice } from "@/mocks"; + +/** Mark a visible pay-date column editable. A hidden Payment Date stays omitted. */ +export function withEditablePayDate(columns: GridColDef[]): GridColDef[] { + return columns.map((column) => + column.field === "payDate" ? { ...column, editable: true } : column, + ); +} diff --git a/src/dashboard/column-prefs.test.ts b/src/dashboard/column-prefs.test.ts new file mode 100644 index 0000000..c1ba789 --- /dev/null +++ b/src/dashboard/column-prefs.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + COLUMN_PREFS_STORAGE_KEY, + defaultColumnPrefs, + hiddenColumnKeys, + loadColumnPrefs, + resetColumnPrefs, + saveColumnPrefs, +} from "@/dashboard/column-prefs"; +import { DEFAULT_VISIBLE_COLUMN_KEYS } from "@/dashboard/column-catalog"; + +describe("column-prefs", () => { + afterEach(() => { + sessionStorage.removeItem(COLUMN_PREFS_STORAGE_KEY); + }); + + it("defaults to HTML Visible order", () => { + expect(defaultColumnPrefs().visibleOrder).toEqual([...DEFAULT_VISIBLE_COLUMN_KEYS]); + }); + + it("persists and reloads prefs", () => { + saveColumnPrefs({ visibleOrder: ["invoiceId", "vendor", "dueDate"] }); + expect(loadColumnPrefs().visibleOrder).toEqual([ + "bolt", + "bell", + "invoiceId", + "vendor", + "dueDate", + ]); + }); + + it("reset restores defaults", () => { + saveColumnPrefs({ visibleOrder: ["invoiceId"] }); + expect(resetColumnPrefs().visibleOrder).toEqual([...DEFAULT_VISIBLE_COLUMN_KEYS]); + }); + + it("computes hidden keys from visible order", () => { + const hidden = hiddenColumnKeys(["invoiceId", "vendor"]); + expect(hidden).toContain("tagging"); + expect(hidden).not.toContain("invoiceId"); + }); +}); diff --git a/src/dashboard/column-prefs.ts b/src/dashboard/column-prefs.ts new file mode 100644 index 0000000..da750ea --- /dev/null +++ b/src/dashboard/column-prefs.ts @@ -0,0 +1,66 @@ +import { + DEFAULT_VISIBLE_COLUMN_KEYS, + INVOICE_COLUMN_CATALOG, + LOCKED_COLUMN_KEYS, + type InvoiceColumnKey, +} from "@/dashboard/column-catalog"; + +export const COLUMN_PREFS_STORAGE_KEY = "seahaven-ap.invoice-columns.v1"; + +export type ColumnPrefs = { + visibleOrder: InvoiceColumnKey[]; +}; + +export function defaultColumnPrefs(): ColumnPrefs { + return { visibleOrder: [...DEFAULT_VISIBLE_COLUMN_KEYS] }; +} + +export function loadColumnPrefs(): ColumnPrefs { + if (typeof sessionStorage === "undefined") { + return defaultColumnPrefs(); + } + try { + const raw = sessionStorage.getItem(COLUMN_PREFS_STORAGE_KEY); + if (!raw) { + return defaultColumnPrefs(); + } + const parsed = JSON.parse(raw) as ColumnPrefs; + if (!Array.isArray(parsed.visibleOrder) || parsed.visibleOrder.length === 0) { + return defaultColumnPrefs(); + } + const known = new Set(INVOICE_COLUMN_CATALOG.map((entry) => entry.key)); + const visibleOrder = parsed.visibleOrder.filter((key) => known.has(key)); + const missingLocked = LOCKED_COLUMN_KEYS.filter((key) => !visibleOrder.includes(key)); + return { visibleOrder: [...missingLocked, ...visibleOrder] }; + } catch { + return defaultColumnPrefs(); + } +} + +export function saveColumnPrefs(prefs: ColumnPrefs): void { + if (typeof sessionStorage === "undefined") { + return; + } + sessionStorage.setItem(COLUMN_PREFS_STORAGE_KEY, JSON.stringify(prefs)); +} + +export function resetColumnPrefs(): ColumnPrefs { + const prefs = defaultColumnPrefs(); + saveColumnPrefs(prefs); + return prefs; +} + +export function hiddenColumnKeys(visibleOrder: InvoiceColumnKey[]): InvoiceColumnKey[] { + const visible = new Set(visibleOrder); + return INVOICE_COLUMN_CATALOG.filter((entry) => !visible.has(entry.key)).map( + (entry) => entry.key, + ); +} + +export function isColumnLocked(key: InvoiceColumnKey): boolean { + return LOCKED_COLUMN_KEYS.includes(key); +} + +export function catalogLabel(key: InvoiceColumnKey): string { + return INVOICE_COLUMN_CATALOG.find((entry) => entry.key === key)?.label ?? key; +} diff --git a/src/dashboard/columns.test.ts b/src/dashboard/columns.test.ts index a0cdeb1..a3eb9d3 100644 --- a/src/dashboard/columns.test.ts +++ b/src/dashboard/columns.test.ts @@ -1,4 +1,7 @@ +import type { GridColDef } from "@mui/x-data-grid"; import { describe, expect, it } from "vitest"; +import { withEditablePayDate } from "@/dashboard/approved-columns"; +import type { MockInvoice } from "@/mocks"; import { formatRelativeFromIsoDate, formatSlashDate } from "@/dashboard/invoice-date-format"; describe("invoice column date helpers (AP-47)", () => { @@ -26,3 +29,20 @@ describe("invoice column date helpers (AP-47)", () => { expect(formatRelativeFromIsoDate("2026-10-11", now)).toBe("in 2 months"); }); }); + +describe("withEditablePayDate", () => { + const hidden: GridColDef = { field: "vendorName", headerName: "Vendor" }; + const payDate: GridColDef = { field: "payDate", headerName: "Payment Date" }; + + it("leaves Payment Date hidden when the catalog omits it", () => { + const columns = withEditablePayDate([hidden]); + expect(columns.some((column) => column.field === "payDate")).toBe(false); + }); + + it("keeps a single editable pay date when Payment Date is already visible", () => { + const columns = withEditablePayDate([hidden, { ...payDate, editable: false }]); + const matches = columns.filter((column) => column.field === "payDate"); + expect(matches).toHaveLength(1); + expect(matches[0]?.editable).toBe(true); + }); +}); diff --git a/src/dashboard/columns.tsx b/src/dashboard/columns.tsx index 3b1ded2..c720972 100644 --- a/src/dashboard/columns.tsx +++ b/src/dashboard/columns.tsx @@ -4,6 +4,7 @@ import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNone import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { Box, Link as MuiLink, Stack, Typography } from "@mui/material"; import type { GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { withEditablePayDate } from "@/dashboard/approved-columns"; import type { InvoiceColumnKey } from "@/dashboard/column-catalog"; import { formatRelativeFromIsoDate, formatSlashDate } from "@/dashboard/invoice-date-format"; import type { MockInvoice } from "@/mocks"; @@ -250,6 +251,11 @@ export function columnsFromCatalogKeys(keys: InvoiceColumnKey[]): GridColDef catalogColumnDefs[key]); } +/** Approved tab keeps pay date editable when the drawer shows Payment Date. */ +export function columnsForApprovedGrid(keys: InvoiceColumnKey[]): GridColDef[] { + return withEditablePayDate(columnsFromCatalogKeys(keys)); +} + export const readyToPayColumns: GridColDef[] = [ { field: "invoiceNumber", From f10dddf6f284cf4e23561432f477d524647e7cee Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:35:10 +0000 Subject: [PATCH 5/7] feat(web): add minimal Filter slide-over for invoice processing (AP-46) (#19) * feat(web): fix shell chrome, logo home, and live nav badges Refs: AP-42 * feat(web): add invoice grid lines and bold headers Refs: AP-44 * feat(web): support auto row height for multi-line invoice cells Refs: AP-44 * feat(web): expand mock invoice fields for Stampli column catalog Refs: AP-47 * feat(web): add Action and Vendor filters to invoice processing Refs: AP-43 * feat(web): add multi-line Stampli invoice cell renderers Refs: AP-47 * feat(web): align filter toolbar layout, field shapes, and tabs Refs: AP-43 * feat(web): add Columns slide-over with Stampli HTML defaults Refs: AP-45 * feat(web): add minimal Filter slide-over for invoice processing Refs: AP-46 * fix(web): address review feedback * fix(web): address review feedback * fix(web): address review feedback * ci: remove the pr policy caller * fix(web): keep mock catalog dates and updates in sync * fix(web): render the attention banner as text * fix(web): keep pay date editable on the approved grid * fix(web): make the attention banner non-interactive copy * fix(web): derive mock action labels from invoice status * fix(web): restore link-style attention banner copy * fix(ci): drop the unused catalog refresh index * fix(web): assert the vendor column by its grid field * fix(ci): format invoice mock helpers * fix(web): hide Payment Date when the drawer omits it * test(web): keep filters applied when the drawer closes * fix(web): count filtered rows on invoice tabs --- e2e/invoice-filter-drawer.spec.ts | 31 ++++++++++++++ package-lock.json | 53 +++++++++++++++++++++++ src/dashboard/DashboardGrids.tsx | 9 +++- src/dashboard/FilterDrawer.tsx | 71 +++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 e2e/invoice-filter-drawer.spec.ts create mode 100644 src/dashboard/FilterDrawer.tsx diff --git a/e2e/invoice-filter-drawer.spec.ts b/e2e/invoice-filter-drawer.spec.ts new file mode 100644 index 0000000..4fa4eab --- /dev/null +++ b/e2e/invoice-filter-drawer.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Invoice Processing filter drawer (AP-46)", () => { + test("Filter drawer clears applied filters", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("vendor-filter").fill("Acme"); + await page.getByTestId("filter-toggle").click(); + const drawer = page.getByTestId("filter-drawer"); + await expect(drawer).toBeVisible(); + await expect(page.getByTestId("filter-drawer-vendor")).toContainText("Acme"); + + await page.getByTestId("filter-drawer-clear").click(); + await expect(page.getByTestId("vendor-filter")).toHaveValue(""); + await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toBeHidden(); + }); + + test("closing the filter drawer leaves applied filters in place", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("vendor-filter").fill("Acme"); + await page.getByTestId("filter-toggle").click(); + await expect(page.getByTestId("filter-drawer")).toBeVisible(); + await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toHaveText("1"); + + await page.getByRole("button", { name: "Close filters" }).evaluate((el: HTMLElement) => { + el.click(); + }); + await expect(page.getByTestId("filter-drawer")).toBeHidden(); + await expect(page.getByTestId("vendor-filter")).toHaveValue("Acme"); + await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toHaveText("1"); + }); +}); diff --git a/package-lock.json b/package-lock.json index 7fa5aaf..474ca5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -898,6 +898,59 @@ "react": ">=16.8.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx index ab1f03d..7c93f7d 100644 --- a/src/dashboard/DashboardGrids.tsx +++ b/src/dashboard/DashboardGrids.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { Alert, Box, CircularProgress, Stack, Tab, Tabs, Typography } from "@mui/material"; import type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid"; import { ColumnsDrawer } from "@/dashboard/ColumnsDrawer"; +import { FilterDrawer } from "@/dashboard/FilterDrawer"; import { InvoiceFilterToolbar } from "@/dashboard/InvoiceFilterToolbar"; import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton"; import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid"; @@ -76,7 +77,7 @@ export function DashboardGrids() { const [selection, setSelection] = useState(emptySelection); const [filters, setFilters] = useState(emptyFilters); const [columnsDrawerOpen, setColumnsDrawerOpen] = useState(false); - const [, setFilterDrawerOpen] = useState(false); + const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [columnPrefs, setColumnPrefs] = useState(() => loadColumnPrefs()); const { data: invoices = [], isLoading, isError, error } = useInvoices(); const updatePayDate = useUpdateInvoicePayDate(); @@ -330,6 +331,12 @@ export function DashboardGrids() { setColumnPrefs(next); }} /> + setFilterDrawerOpen(false)} + filters={filters} + onFiltersChange={setFilters} + /> ); } diff --git a/src/dashboard/FilterDrawer.tsx b/src/dashboard/FilterDrawer.tsx new file mode 100644 index 0000000..470a880 --- /dev/null +++ b/src/dashboard/FilterDrawer.tsx @@ -0,0 +1,71 @@ +import CloseIcon from "@mui/icons-material/Close"; +import { Box, Button, Drawer, IconButton, Stack, Typography } from "@mui/material"; +import type { ReactElement } from "react"; +import { countActiveFilters, type InvoiceFilterState } from "@/dashboard/invoice-filters"; + +type FilterDrawerProps = { + open: boolean; + onClose: () => void; + filters: InvoiceFilterState; + onFiltersChange: (next: InvoiceFilterState) => void; +}; + +export function FilterDrawer({ + open, + onClose, + filters, + onFiltersChange, +}: FilterDrawerProps): ReactElement { + const activeCount = countActiveFilters(filters); + + return ( + + + Filters + + + + + + + {activeCount === 0 + ? "No filters applied." + : `${activeCount} active filter${activeCount === 1 ? "" : "s"}.`} + + + + + + Action + + + {filters.action ?? "—"} + + + + + Vendor + + + {filters.vendorQuery.trim() || "—"} + + + + + + + ); +} From 81d5d5c97a76dcb8becc04686852674217d65189 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:39:24 +0000 Subject: [PATCH 6/7] chore(deps): update dependency vitest to v5 (#63) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 242 ++++++++++------------------------- package.json | 2 +- packages/api/package.json | 2 +- packages/shared/package.json | 2 +- 4 files changed, 74 insertions(+), 174 deletions(-) diff --git a/package-lock.json b/package-lock.json index 474ca5b..87cdce2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.70.0", "vite": "^8.3.0", - "vitest": "^4.1.11" + "vitest": "^5.0.1" }, "engines": { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" @@ -2295,9 +2295,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -3286,13 +3286,6 @@ "solid-js": "^1.6.12" } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -3852,34 +3845,17 @@ } } }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^1.2.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3897,81 +3873,26 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "node_modules/@vitest/mocker/node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -5078,9 +4999,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", - "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -6240,16 +6161,6 @@ "lz-string": "bin/bin.js" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -6497,13 +6408,6 @@ "node": ">=8" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/pg": { "version": "8.23.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", @@ -7283,11 +7187,14 @@ } }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { "version": "1.3.0", @@ -7344,16 +7251,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/tldts": { "version": "7.4.10", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", @@ -7658,38 +7555,31 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -7697,16 +7587,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -7747,6 +7637,16 @@ } } }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", @@ -7939,7 +7839,7 @@ "drizzle-kit": "^0.31.10", "tsx": "^4.23.13", "typescript": "^5.9.3", - "vitest": "^4.1.11" + "vitest": "^5.0.1" }, "engines": { "node": ">=24.21.0" @@ -7950,7 +7850,7 @@ "version": "0.1.0", "devDependencies": { "typescript": "^5.9.3", - "vitest": "^4.1.11" + "vitest": "^5.0.1" }, "engines": { "node": ">=24.21.0" diff --git a/package.json b/package.json index 7477812..b8ed615 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,6 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.70.0", "vite": "^8.3.0", - "vitest": "^4.1.11" + "vitest": "^5.0.1" } } diff --git a/packages/api/package.json b/packages/api/package.json index 0ef41fd..fc61bf9 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -45,7 +45,7 @@ "drizzle-kit": "^0.31.10", "tsx": "^4.23.13", "typescript": "^5.9.3", - "vitest": "^4.1.11" + "vitest": "^5.0.1" }, "engines": { "node": ">=24.21.0" diff --git a/packages/shared/package.json b/packages/shared/package.json index 14f1654..a0eff5c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -23,7 +23,7 @@ }, "devDependencies": { "typescript": "^5.9.3", - "vitest": "^4.1.11" + "vitest": "^5.0.1" }, "engines": { "node": ">=24.21.0" From 864531b51e4c432300a0b21219d71bfd1469bb9f Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:43:44 +0000 Subject: [PATCH 7/7] feat(api): portal contract, cookie auth, and domain stubs (AP-51) (#64) * feat(api): serve portal-shaped health and error envelope Move liveness to GET /api/health { stage, sha } with a Node 24 image on 8080 so ALB probes and deploy verify do not need auth or a database ping. * feat(api): switch live auth to host cookie BFF Replace Bearer as the documented session path with Cognito hosted UI plus __Host-ap_* cookies so the SPA can call /api with credentials include. * feat(web): add unused cookie SPA API client Land a credentials-include fetch helper and hand-synced health/me types without wiring pages or domain hooks, so mocks stay the default data path. * feat(api): add master-data OpenAPI and Hono stubs * feat(api): add invoice, line, and document stubs * feat(api): add approval policy, inbox, and activity stubs * test(web): fix SPA client fetch mock types * test(web): cast fetch mock call args for tsc * fix(api): do not default DEV_AUTH_BYPASS outside local migrate * fix(api): replace invoice lines in a single transaction * fix(api): create invoices and lines in one transaction * fix(api): inline GIT_SHA from the image build arg * fix(api): stop PATCH from skipping the approval workflow * fix(api): address review feedback * fix(ci): format upsert-user test * fix(api): document only the auth statuses the routes return * fix(api): drop health 400 responses the routes never return --- .dockerignore | 21 + .env.example | 3 + .redocly.lint-ignore.yaml | 17 +- Dockerfile | 27 + README.md | 6 +- package-lock.json | 74 +++ packages/api/docs/auth.md | 33 +- packages/api/docs/index.md | 7 +- packages/api/docs/local-dev.md | 6 +- packages/api/openapi/components/schemas.yaml | 440 ++++++++++++++++- packages/api/openapi/components/security.yaml | 10 +- packages/api/openapi/openapi.yaml | 100 +++- .../openapi/paths/approval-policies-id.yaml | 91 ++++ .../api/openapi/paths/approval-policies.yaml | 71 +++ .../paths/approval-steps-id-decisions.yaml | 57 +++ packages/api/openapi/paths/auth-callback.yaml | 32 ++ packages/api/openapi/paths/auth-login.yaml | 30 ++ packages/api/openapi/paths/auth-logout.yaml | 21 + packages/api/openapi/paths/auth-refresh.yaml | 32 ++ .../api/openapi/paths/departments-id.yaml | 82 ++++ packages/api/openapi/paths/departments.yaml | 68 +++ .../paths/documents-id-confirmations.yaml | 52 ++ packages/api/openapi/paths/documents-id.yaml | 39 ++ .../api/openapi/paths/gl-accounts-id.yaml | 82 ++++ packages/api/openapi/paths/gl-accounts.yaml | 68 +++ packages/api/openapi/paths/health.yaml | 28 +- packages/api/openapi/paths/inbox.yaml | 24 + .../paths/invoices-id-activity-logs.yaml | 45 ++ .../openapi/paths/invoices-id-comments.yaml | 80 +++ .../openapi/paths/invoices-id-documents.yaml | 53 ++ .../api/openapi/paths/invoices-id-lines.yaml | 123 +++++ packages/api/openapi/paths/invoices-id.yaml | 107 ++++ packages/api/openapi/paths/invoices.yaml | 106 ++++ packages/api/openapi/paths/me.yaml | 19 +- packages/api/openapi/paths/ready.yaml | 28 ++ packages/api/openapi/paths/users-id.yaml | 81 +++ packages/api/openapi/paths/users.yaml | 24 + packages/api/openapi/paths/vendors-id.yaml | 86 ++++ packages/api/openapi/paths/vendors.yaml | 72 +++ packages/api/package.json | 2 + packages/api/src/app.test.ts | 184 ++++++- packages/api/src/app.ts | 57 ++- packages/api/src/approvals.ts | 95 ++++ packages/api/src/auth/cognito.ts | 104 ++++ packages/api/src/auth/cookies.test.ts | 138 ++++++ packages/api/src/auth/cookies.ts | 248 ++++++++++ packages/api/src/auth/middleware.ts | 71 ++- packages/api/src/auth/oauth.test.ts | 20 + packages/api/src/auth/oauth.ts | 229 +++++++++ packages/api/src/auth/origin-verify.ts | 18 + packages/api/src/auth/pkce.ts | 23 + packages/api/src/auth/upsert-user.test.ts | 83 +++- packages/api/src/auth/upsert-user.ts | 26 +- packages/api/src/build-info.ts | 9 + packages/api/src/db/client.ts | 11 + packages/api/src/db/migrate.ts | 7 +- packages/api/src/documents.ts | 77 +++ packages/api/src/env.test.ts | 21 +- packages/api/src/env.ts | 39 +- packages/api/src/http.ts | 57 +++ packages/api/src/index.ts | 4 +- packages/api/src/routes/approvals.test.ts | 160 ++++++ packages/api/src/routes/approvals.ts | 293 +++++++++++ packages/api/src/routes/auth.ts | 16 + packages/api/src/routes/departments.ts | 80 +++ packages/api/src/routes/gl-accounts.ts | 78 +++ packages/api/src/routes/health.ts | 16 +- packages/api/src/routes/helpers.ts | 94 ++++ packages/api/src/routes/invoices.test.ts | 261 ++++++++++ packages/api/src/routes/invoices.ts | 463 ++++++++++++++++++ packages/api/src/routes/master-data.test.ts | 102 ++++ packages/api/src/routes/me.ts | 3 +- packages/api/src/routes/users.ts | 61 +++ packages/api/src/routes/vendors.ts | 97 ++++ packages/api/src/test/fake-db.ts | 261 ++++++++++ packages/api/tsconfig.build.json | 2 +- redocly.yaml | 22 +- src/api/client.test.ts | 58 +++ src/api/client.ts | 87 ++++ src/api/types.ts | 123 +++++ 80 files changed, 5992 insertions(+), 153 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 packages/api/openapi/paths/approval-policies-id.yaml create mode 100644 packages/api/openapi/paths/approval-policies.yaml create mode 100644 packages/api/openapi/paths/approval-steps-id-decisions.yaml create mode 100644 packages/api/openapi/paths/auth-callback.yaml create mode 100644 packages/api/openapi/paths/auth-login.yaml create mode 100644 packages/api/openapi/paths/auth-logout.yaml create mode 100644 packages/api/openapi/paths/auth-refresh.yaml create mode 100644 packages/api/openapi/paths/departments-id.yaml create mode 100644 packages/api/openapi/paths/departments.yaml create mode 100644 packages/api/openapi/paths/documents-id-confirmations.yaml create mode 100644 packages/api/openapi/paths/documents-id.yaml create mode 100644 packages/api/openapi/paths/gl-accounts-id.yaml create mode 100644 packages/api/openapi/paths/gl-accounts.yaml create mode 100644 packages/api/openapi/paths/inbox.yaml create mode 100644 packages/api/openapi/paths/invoices-id-activity-logs.yaml create mode 100644 packages/api/openapi/paths/invoices-id-comments.yaml create mode 100644 packages/api/openapi/paths/invoices-id-documents.yaml create mode 100644 packages/api/openapi/paths/invoices-id-lines.yaml create mode 100644 packages/api/openapi/paths/invoices-id.yaml create mode 100644 packages/api/openapi/paths/invoices.yaml create mode 100644 packages/api/openapi/paths/ready.yaml create mode 100644 packages/api/openapi/paths/users-id.yaml create mode 100644 packages/api/openapi/paths/users.yaml create mode 100644 packages/api/openapi/paths/vendors-id.yaml create mode 100644 packages/api/openapi/paths/vendors.yaml create mode 100644 packages/api/src/approvals.ts create mode 100644 packages/api/src/auth/cognito.ts create mode 100644 packages/api/src/auth/cookies.test.ts create mode 100644 packages/api/src/auth/cookies.ts create mode 100644 packages/api/src/auth/oauth.test.ts create mode 100644 packages/api/src/auth/oauth.ts create mode 100644 packages/api/src/auth/origin-verify.ts create mode 100644 packages/api/src/auth/pkce.ts create mode 100644 packages/api/src/build-info.ts create mode 100644 packages/api/src/documents.ts create mode 100644 packages/api/src/http.ts create mode 100644 packages/api/src/routes/approvals.test.ts create mode 100644 packages/api/src/routes/approvals.ts create mode 100644 packages/api/src/routes/auth.ts create mode 100644 packages/api/src/routes/departments.ts create mode 100644 packages/api/src/routes/gl-accounts.ts create mode 100644 packages/api/src/routes/helpers.ts create mode 100644 packages/api/src/routes/invoices.test.ts create mode 100644 packages/api/src/routes/invoices.ts create mode 100644 packages/api/src/routes/master-data.test.ts create mode 100644 packages/api/src/routes/users.ts create mode 100644 packages/api/src/routes/vendors.ts create mode 100644 packages/api/src/test/fake-db.ts create mode 100644 src/api/client.test.ts create mode 100644 src/api/client.ts create mode 100644 src/api/types.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6bb9975 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.git +.github +.cursor +dist +packages/*/dist +build +coverage +e2e +node_modules +src +public +placeholder +terraform +docs +*.md +.env +.env.* +playwright-report +test-results +.idea +.vscode diff --git a/.env.example b/.env.example index 6d07ee7..9099a4d 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,9 @@ DEV_AUTH_ROLE=admin # Cognito (required when DEV_AUTH_BYPASS=false) # COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/ # COGNITO_AUDIENCE= +# COGNITO_DOMAIN= +# APP_ORIGIN=http://127.0.0.1:3000 +# ORIGIN_VERIFY_SECRET= # Aurora Data API driver (DATABASE_DRIVER=data-api) # AWS_REGION=us-east-1 diff --git a/.redocly.lint-ignore.yaml b/.redocly.lint-ignore.yaml index 871a222..7b3f1bf 100644 --- a/.redocly.lint-ignore.yaml +++ b/.redocly.lint-ignore.yaml @@ -1,5 +1,16 @@ # This file instructs Redocly's linter to ignore the rules contained for specific parts of your API. # See https://redocly.com/docs/cli/ for more information. -# -# Intentionally empty for AP-14 foundation. Add justified ignores in the same -# style as Sea-Haven-Industries/procurement-ingest when needed. +# Login and callback fail with a 302 redirect, or 500 when Cognito is not configured. +packages/api/openapi/paths/auth-login.yaml: + operation-4xx-response: + - '#/get/responses' +packages/api/openapi/paths/auth-callback.yaml: + operation-4xx-response: + - '#/get/responses' +# Health returns 200. Ready returns 200 or 503. Neither returns 4xx. +packages/api/openapi/paths/health.yaml: + operation-4xx-response: + - '#/get/responses' +packages/api/openapi/paths/ready.yaml: + operation-4xx-response: + - '#/get/responses' diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..01279b5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM --platform=$BUILDPLATFORM node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 AS build +WORKDIR /app +COPY package.json package-lock.json ./ +COPY packages/shared/package.json packages/shared/package.json +COPY packages/api/package.json packages/api/package.json +RUN npm ci +COPY packages/shared packages/shared +COPY packages/api packages/api +RUN npm run build:shared && npm run build -w @seahaven-ap/api +ARG GIT_SHA=unknown +RUN GIT_SHA="$GIT_SHA" node -e "require('node:fs').writeFileSync('packages/api/dist/build-info.js', 'export const BUILD_GIT_SHA = ' + JSON.stringify(process.env.GIT_SHA || 'unknown') + ';\\n')" + +FROM --platform=linux/amd64 node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 +RUN addgroup -S app && adduser -S -G app app +WORKDIR /app +COPY package.json package-lock.json ./ +COPY packages/shared/package.json packages/shared/package.json +COPY packages/api/package.json packages/api/package.json +RUN npm ci --omit=dev +COPY --from=build /app/packages/shared/dist packages/shared/dist +COPY --from=build /app/packages/api/dist packages/api/dist +COPY --from=build /app/packages/api/drizzle packages/api/drizzle +USER app +ENV NODE_ENV=production \ + API_PORT=8080 +EXPOSE 8080 +CMD ["node", "packages/api/dist/index.js"] diff --git a/README.md b/README.md index 3ad12bc..480e36c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Sea Haven AP -Internal accounts payable automation for Sea Haven Industries (`ap.seahaven.com`). +Internal accounts payable automation for Sea Haven Industries. Local API is `http://127.0.0.1:8787`. CloudFront on seahaven-dev is the first hosted origin. ## Workspace layout @@ -37,11 +37,11 @@ API listens on http://127.0.0.1:8787. Vite proxies `/api` to that port. Smoke: ```bash -curl -s http://127.0.0.1:8787/health +curl -s http://127.0.0.1:8787/api/health curl -s http://127.0.0.1:8787/api/me ``` -`DEV_AUTH_BYPASS=true` is local-only and only allowed when `NODE_ENV` is `development` or `test` (rejected for production, staging, preview, and any other value). +`DEV_AUTH_BYPASS=true` is local-only and only allowed when `NODE_ENV` is `development` or `test` (rejected for production, staging, preview, and any other value). Cookie session names are `ap_*` locally and `__Host-ap_*` outside local. API roles (source of truth): `admin`, `ap_processor`, `approver`, `viewer`. Frontend mocks still use `ap_operator` until AP-15 remaps them. diff --git a/package-lock.json b/package-lock.json index 87cdce2..4e3c4d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -116,6 +116,22 @@ "node": "20 || >=22" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1001.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.0.tgz", + "integrity": "sha512-6uTniZc87q+B5eXouGTl+7Tmc482rEeCcvxpsvREP8EfF0gvloRZ41UOA9sbSJlyy8TbqIBXb3kKfKarEArUQA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-rds-data": { "version": "3.1136.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-rds-data/-/client-rds-data-3.1136.0.tgz", @@ -135,6 +151,28 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1137.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1137.0.tgz", + "integrity": "sha512-ppiYnDyy2qCDT3PIV83XJwAi0BhVUZ/jOHxUgC5tlMCaFeKRL8PVVub8A0pUMkF1yvZtzNOp3ClbmTCcIa9w3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1001.0", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/credential-provider-node": "^3.972.83", + "@aws-sdk/middleware-sdk-s3": "^3.972.76", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/core": { "version": "3.978.0", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.0.tgz", @@ -302,6 +340,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.76.tgz", + "integrity": "sha512-NfnTkVUTBKTBuBgqaapFK9r3YdkKt1b2oRvgLzZq91bwNKh6ZS0S7sEcheguttREaL4iyfs/xQnqD7Z7AsWSsA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/nested-clients": { "version": "3.997.45", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.45.tgz", @@ -321,6 +376,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1137.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1137.0.tgz", + "integrity": "sha512-OJQwS0qt5fQMoZSccncZQBLLvSZ3Jw7Lo+E3MYBNNPRnUkvJxn5LeY246K+1QvoL9o8zHpYVPa7YgCL+zf+xtg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/signature-v4-multi-region": { "version": "3.996.46", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", @@ -7825,6 +7897,8 @@ "version": "0.1.0", "dependencies": { "@aws-sdk/client-rds-data": "^3.1135.0", + "@aws-sdk/client-s3": "^3.1137.0", + "@aws-sdk/s3-request-presigner": "^3.1137.0", "@hono/node-server": "^2.1.1", "@seahaven-ap/shared": "*", "drizzle-orm": "^0.45.2", diff --git a/packages/api/docs/auth.md b/packages/api/docs/auth.md index 9d3e01c..a52b634 100644 --- a/packages/api/docs/auth.md +++ b/packages/api/docs/auth.md @@ -1,9 +1,32 @@ # Authentication -## Cognito bearer tokens +## Cookie session (live path) -Production and seahaven-dev expect a Bearer Cognito token verified against the -user pool JWKS and issuer. +The API is a same-origin BFF. Cognito hosted UI issues tokens. The API stores +them in host-only cookies: + +- `__Host-ap_at` access token (HttpOnly) +- `__Host-ap_it` ID token (HttpOnly) +- `__Host-ap_rt` refresh token (HttpOnly, path `/` when host-prefixed) +- `__Host-ap_sess` session hint (not HttpOnly; display name and email) +- `__Host-ap_oauth` PKCE state during login + +Local `NODE_ENV` `development` or `test` drops the `__Host-` prefix and the +Secure flag so `http://127.0.0.1:8787` works (`ap_at`, `ap_it`, `ap_rt`). + +Routes: + +- `GET /api/auth/login` +- `GET /api/auth/callback` +- `POST /api/auth/refresh` +- `POST /api/auth/logout` +- `GET /api/me` reads the ID cookie, verifies it, and upserts `users` by `sub` + +CloudFront sends `X-Origin-Verify` on `/api/*`. `GET /api/health` skips that +check so the ALB probe succeeds. Mutating `/api/*` requests also require a +matching `Origin`. + +## Cognito tokens Audience check: @@ -11,9 +34,7 @@ Audience check: - Access tokens (`token_use=access`): `client_id` must equal `COGNITO_AUDIENCE`. Identity claims (`sub`, `email`, and `name` or `cognito:username`) are required. -Prefer a Cognito **ID token**, which carries email/name by default. An access -token is accepted only when it includes an `email` claim (for example via a -pre-token-generation enrichment). +`GET /api/me` uses the ID cookie. Optional role claim mapping: diff --git a/packages/api/docs/index.md b/packages/api/docs/index.md index ca1808a..a403a84 100644 --- a/packages/api/docs/index.md +++ b/packages/api/docs/index.md @@ -1,6 +1,7 @@ # Sea Haven AP API -HTTP API for Sea Haven accounts payable (`ap.seahaven.com`). +HTTP API for Sea Haven accounts payable. Local server is `http://127.0.0.1:8787`. -This foundation documents the health and session smoke surface introduced in -AP-14. Domain CRUD lands in later tickets and extends this OpenAPI tree. +Liveness is `GET /api/health` (`{ stage, sha }`) with no auth and no database +ping. Readiness is optional `GET /api/ready`. Errors use +`{ error: { code, message, correlationId } }`. diff --git a/packages/api/docs/local-dev.md b/packages/api/docs/local-dev.md index 7b02dcd..92b7ef9 100644 --- a/packages/api/docs/local-dev.md +++ b/packages/api/docs/local-dev.md @@ -18,7 +18,7 @@ Defaults: ## Smoke ```bash -curl -s http://127.0.0.1:8787/health +curl -s http://127.0.0.1:8787/api/health curl -s http://127.0.0.1:8787/api/me ``` @@ -34,5 +34,5 @@ npm run docs:preview ``` `docs:preview` runs `redocly build-docs` (CLI v2) and opens the HTML at -`/tmp/seahaven-ap-api-docs.html`. Published OpenAPI servers point at -`https://ap.seahaven.com`. Use this page for the local docs view. +`/tmp/seahaven-ap-api-docs.html`. OpenAPI servers point at +`http://127.0.0.1:8787`. Use this page for the local docs view. diff --git a/packages/api/openapi/components/schemas.yaml b/packages/api/openapi/components/schemas.yaml index 8ab8819..98c2009 100644 --- a/packages/api/openapi/components/schemas.yaml +++ b/packages/api/openapi/components/schemas.yaml @@ -4,10 +4,39 @@ Error: - error properties: error: - type: string - description: Human-readable error message. - example: Missing or invalid Authorization header. + type: object + required: + - code + - message + - correlationId + properties: + code: + type: string + description: Machine-readable error code. + example: NOT_FOUND + message: + type: string + description: Human-readable error message. + example: Not found. + correlationId: + type: string + description: Request correlation identifier echoed from x-correlation-id when present. + example: 11111111-1111-4111-8111-111111111111 HealthResponse: + type: object + required: + - stage + - sha + properties: + stage: + type: string + description: Deployment stage name. + example: local + sha: + type: string + description: Git SHA inlined at image build. + example: deadbeef +ReadyResponse: type: object required: - status @@ -15,7 +44,7 @@ HealthResponse: properties: status: type: string - description: Process health marker. + description: Process readiness marker. example: ok database: type: string @@ -52,3 +81,406 @@ MeResponse: - approver - viewer example: admin +Vendor: + type: object + required: + - id + - name + - defaultPaymentMethod + - createdAt + - updatedAt + properties: + id: + type: string + format: uuid + description: Vendor primary key. + example: 55555555-5555-4555-8555-555555555555 + name: + type: string + description: Vendor display name. + example: Acme Facilities Supply + email: + type: [string, "null"] + description: Billing email when present. + example: billing@acmefacilities.example + defaultPaymentMethod: + type: string + enum: [check, ach] + description: Default payment method for new invoices. + example: check + createdAt: + type: string + format: date-time + description: Row creation time. + updatedAt: + type: string + format: date-time + description: Row update time. +GlAccount: + type: object + required: [id, code, name, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + description: GL account primary key. + example: 66666666-6666-4666-8666-666666666666 + code: + type: string + description: Account code. + example: "6100" + name: + type: string + description: Account name. + example: Facilities Expense + createdAt: + type: string + format: date-time + description: Row creation time. + updatedAt: + type: string + format: date-time + description: Row update time. +Department: + type: object + required: [id, code, name, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + description: Department primary key. + example: 77777777-7777-4777-8777-777777777777 + code: + type: string + description: Department code. + example: OPS + name: + type: string + description: Department name. + example: Operations + createdAt: + type: string + format: date-time + description: Row creation time. + updatedAt: + type: string + format: date-time + description: Row update time. +User: + type: object + required: [id, email, name, role, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + description: User primary key. + example: 11111111-1111-4111-8111-111111111111 + email: + type: string + format: email + description: User email address. + example: admin@seahavenind.com + name: + type: string + description: Display name. + example: Dev Admin + role: + type: string + enum: [admin, ap_processor, approver, viewer] + description: Authorization role. + example: admin + createdAt: + type: string + format: date-time + description: Row creation time. + updatedAt: + type: string + format: date-time + description: Row update time. +Invoice: + type: object + required: + - id + - vendorId + - invoiceNumber + - amount + - amountDue + - dueDate + - status + - paymentMethod + - memo + - createdAt + - updatedAt + - lines + properties: + id: + type: string + format: uuid + description: Invoice primary key. + example: 88888888-8888-4888-8888-888888888888 + vendorId: + type: string + format: uuid + description: Vendor foreign key. + example: 55555555-5555-4555-8555-555555555555 + invoiceNumber: + type: string + description: Vendor-issued invoice number. + example: INV-1001 + amount: + type: string + description: Invoice total as numeric(14,2) text. + example: "1250.00" + amountDue: + type: string + description: Remaining amount due as numeric(14,2) text. + example: "1250.00" + dueDate: + type: string + description: Due date as YYYY-MM-DD. + example: "2026-09-01" + payDate: + type: [string, "null"] + description: Optional pay date as YYYY-MM-DD. + example: "2026-09-15" + sendPaymentOn: + type: [string, "null"] + description: Optional send date as YYYY-MM-DD. + example: "2026-09-10" + status: + type: string + enum: [pending_approval, approved, scheduled, paid, rejected, void] + description: Invoice workflow status. + example: pending_approval + paymentMethod: + type: string + enum: [check, ach] + description: Payment method for this invoice. + example: check + memo: + type: string + description: Free-form memo. + example: Seed invoice for local smoke. + createdAt: + type: string + format: date-time + description: Row creation time. + updatedAt: + type: string + format: date-time + description: Row update time. + lines: + type: array + description: Coding lines attached to the invoice. + items: + $ref: "#/InvoiceLine" +InvoiceLine: + type: object + required: [id, invoiceId, description, amount, createdAt] + properties: + id: + type: string + format: uuid + description: Line primary key. + example: 99999999-9999-4999-8999-999999999999 + invoiceId: + type: string + format: uuid + description: Parent invoice id. + example: 88888888-8888-4888-8888-888888888888 + description: + type: string + description: Line description. + example: Monthly maintenance + amount: + type: string + description: Line amount as numeric(14,2) text. + example: "1250.00" + glAccountId: + type: [string, "null"] + format: uuid + description: Optional GL account id. + departmentId: + type: [string, "null"] + format: uuid + description: Optional department id. + createdAt: + type: string + format: date-time + description: Row creation time. +Document: + type: object + required: [id, objectKey, contentType, fileName, createdAt] + properties: + id: + type: string + format: uuid + description: Document primary key. + example: dddddddd-dddd-4ddd-8ddd-dddddddddddd + invoiceId: + type: [string, "null"] + format: uuid + description: Parent invoice id when attached. + objectKey: + type: string + description: Object key in the documents bucket. + example: seed/inv-1001.pdf + contentType: + type: string + description: Uploaded object MIME type. + example: application/pdf + fileName: + type: string + description: Original file name. + example: inv-1001.pdf + uploadedByUserId: + type: [string, "null"] + format: uuid + description: User who started the upload. + createdAt: + type: string + format: date-time + description: Row creation time. + uploadUrl: + type: string + description: Presigned PUT URL returned on create. + uploadHeaders: + type: object + additionalProperties: + type: string + description: Headers the browser must send with the presigned PUT. + 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/components/security.yaml b/packages/api/openapi/components/security.yaml index 6b16271..ed66f35 100644 --- a/packages/api/openapi/components/security.yaml +++ b/packages/api/openapi/components/security.yaml @@ -1,5 +1,5 @@ -bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: Cognito ID token preferred. Access tokens require an email claim. Local DEV_AUTH_BYPASS skips verification. +cookieAuth: + type: apiKey + in: cookie + name: __Host-ap_it + description: HttpOnly session id-token cookie set by GET /api/auth/callback. Local stage uses ap_it without the __Host- prefix. diff --git a/packages/api/openapi/openapi.yaml b/packages/api/openapi/openapi.yaml index e070c33..4e9d19d 100644 --- a/packages/api/openapi/openapi.yaml +++ b/packages/api/openapi/openapi.yaml @@ -2,30 +2,114 @@ openapi: 3.1.0 info: title: Sea Haven AP API version: 1.0.0 - description: "Accounts payable HTTP API for Sea Haven Industries. Foundation surface for health and authenticated session smoke under local and AWS runtimes." + description: "Accounts payable HTTP API for Sea Haven Industries. Liveness, cookie session, and authenticated session smoke under local and AWS runtimes." license: name: Proprietary servers: - - url: https://ap.seahaven.com - description: Production API host for ap.seahaven.com. + - url: http://127.0.0.1:8787 + description: Local API process used by Vite proxy and unit tests. tags: - name: Health - description: Liveness and dependency checks for the API process. + description: Liveness and readiness checks for the API process. - name: Session - description: Authenticated caller identity after JWT or local dev auth. + description: Cookie session via Cognito hosted UI, plus caller identity after upsert. + - name: Master data + description: Vendors, GL accounts, departments, and user role updates. + - name: Invoices + 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: - /health: + /api/health: $ref: ./paths/health.yaml + /api/ready: + $ref: ./paths/ready.yaml + /api/auth/login: + $ref: ./paths/auth-login.yaml + /api/auth/callback: + $ref: ./paths/auth-callback.yaml + /api/auth/refresh: + $ref: ./paths/auth-refresh.yaml + /api/auth/logout: + $ref: ./paths/auth-logout.yaml /api/me: $ref: ./paths/me.yaml + /api/vendors: + $ref: ./paths/vendors.yaml + /api/vendors/{id}: + $ref: ./paths/vendors-id.yaml + /api/gl-accounts: + $ref: ./paths/gl-accounts.yaml + /api/gl-accounts/{id}: + $ref: ./paths/gl-accounts-id.yaml + /api/departments: + $ref: ./paths/departments.yaml + /api/departments/{id}: + $ref: ./paths/departments-id.yaml + /api/users: + $ref: ./paths/users.yaml + /api/users/{id}: + $ref: ./paths/users-id.yaml + /api/invoices: + $ref: ./paths/invoices.yaml + /api/invoices/{id}: + $ref: ./paths/invoices-id.yaml + /api/invoices/{id}/lines: + $ref: ./paths/invoices-id-lines.yaml + /api/invoices/{id}/documents: + $ref: ./paths/invoices-id-documents.yaml + /api/documents/{id}: + $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: - bearerAuth: - $ref: ./components/security.yaml#/bearerAuth + cookieAuth: + $ref: ./components/security.yaml#/cookieAuth schemas: Error: $ref: ./components/schemas.yaml#/Error HealthResponse: $ref: ./components/schemas.yaml#/HealthResponse + ReadyResponse: + $ref: ./components/schemas.yaml#/ReadyResponse MeResponse: $ref: ./components/schemas.yaml#/MeResponse + Vendor: + $ref: ./components/schemas.yaml#/Vendor + GlAccount: + $ref: ./components/schemas.yaml#/GlAccount + Department: + $ref: ./components/schemas.yaml#/Department + User: + $ref: ./components/schemas.yaml#/User + Invoice: + $ref: ./components/schemas.yaml#/Invoice + InvoiceLine: + $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/auth-callback.yaml b/packages/api/openapi/paths/auth-callback.yaml new file mode 100644 index 0000000..b8566a8 --- /dev/null +++ b/packages/api/openapi/paths/auth-callback.yaml @@ -0,0 +1,32 @@ +get: + tags: + - Session + summary: Complete hosted UI sign-in + description: Exchanges the authorization code, sets HttpOnly session cookies, and redirects to returnTo. + operationId: get-api-auth-callback + security: [] + parameters: + - name: code + in: query + required: false + description: Authorization code from Cognito. + schema: + type: string + example: abcdef + - name: state + in: query + required: false + description: PKCE state echoed from login. + schema: + type: string + example: state-token + - name: error + in: query + required: false + description: Cognito error code when sign-in failed. + schema: + type: string + example: access_denied + responses: + "302": + description: Redirect to the SPA or the login error page. diff --git a/packages/api/openapi/paths/auth-login.yaml b/packages/api/openapi/paths/auth-login.yaml new file mode 100644 index 0000000..84f7c5f --- /dev/null +++ b/packages/api/openapi/paths/auth-login.yaml @@ -0,0 +1,30 @@ +get: + tags: + - Session + summary: Start hosted UI sign-in + description: Redirects the browser to Cognito hosted UI with PKCE S256. Sets the oauth cookie. + operationId: get-api-auth-login + security: [] + parameters: + - name: returnTo + in: query + required: false + description: Relative path to return to after sign-in. + schema: + type: string + default: / + example: /invoices + responses: + "302": + description: Redirect to Cognito hosted UI. + "500": + description: Cognito is not configured. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: INTERNAL_ERROR + message: Cognito is not configured. + correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/auth-logout.yaml b/packages/api/openapi/paths/auth-logout.yaml new file mode 100644 index 0000000..71b0b42 --- /dev/null +++ b/packages/api/openapi/paths/auth-logout.yaml @@ -0,0 +1,21 @@ +post: + tags: + - Session + summary: End the API session + description: Clears session cookies. Idempotent when already signed out. + operationId: post-api-auth-logout + security: [] + responses: + "204": + description: Session cleared. + "403": + description: CSRF origin check failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: FORBIDDEN + message: Origin is not allowed. + correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/auth-refresh.yaml b/packages/api/openapi/paths/auth-refresh.yaml new file mode 100644 index 0000000..4b8af3a --- /dev/null +++ b/packages/api/openapi/paths/auth-refresh.yaml @@ -0,0 +1,32 @@ +post: + tags: + - Session + summary: Refresh the session cookies + description: Rotates HttpOnly token cookies when the refresh token is still valid. + operationId: post-api-auth-refresh + security: [] + responses: + "204": + description: Session refreshed. + "401": + description: Missing or invalid refresh token. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: UNAUTHENTICATED + message: Missing refresh token. + correlationId: 11111111-1111-4111-8111-111111111111 + "403": + description: CSRF origin check failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: FORBIDDEN + message: Origin is not allowed. + correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/departments-id.yaml b/packages/api/openapi/paths/departments-id.yaml new file mode 100644 index 0000000..b91b01b --- /dev/null +++ b/packages/api/openapi/paths/departments-id.yaml @@ -0,0 +1,82 @@ +parameters: + - name: id + in: path + required: true + description: Department primary key. + schema: + type: string + format: uuid + example: 77777777-7777-4777-8777-777777777777 +get: + tags: [Master data] + summary: Get a department + description: Returns one department by id. + operationId: get-api-departments-id + responses: + "200": + description: Department. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Department + "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: Department not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +patch: + tags: [Master data] + summary: Update a department + description: Admin-only patch. Requires admin:settings. + operationId: patch-api-departments-id + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + example: OPS + name: + type: string + example: Operations + responses: + "200": + description: Updated department. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Department + "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: Department not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/departments.yaml b/packages/api/openapi/paths/departments.yaml new file mode 100644 index 0000000..3355b2d --- /dev/null +++ b/packages/api/openapi/paths/departments.yaml @@ -0,0 +1,68 @@ +get: + tags: [Master data] + summary: List departments + description: Returns every department row. + operationId: get-api-departments + responses: + "200": + description: Department list. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/Department + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +post: + tags: [Master data] + summary: Create a department + description: Admin-only insert. Requires admin:settings. + operationId: post-api-departments + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code, name] + properties: + code: + type: string + example: FIN + name: + type: string + example: Finance + responses: + "201": + description: Created department. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Department + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + 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/documents-id-confirmations.yaml b/packages/api/openapi/paths/documents-id-confirmations.yaml new file mode 100644 index 0000000..046f263 --- /dev/null +++ b/packages/api/openapi/paths/documents-id-confirmations.yaml @@ -0,0 +1,52 @@ +parameters: + - name: id + in: path + required: true + description: Document primary key. + schema: + type: string + format: uuid + example: dddddddd-dddd-4ddd-8ddd-dddddddddddd +post: + tags: [Documents] + summary: Confirm a document upload + description: Succeeds when the object exists in MinIO or S3. Missing objects are a conflict. + operationId: post-api-documents-id-confirmations + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + responses: + "200": + description: Confirmed document with download URL. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Document + "400": + description: Invalid id. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Document not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "409": + description: Object has not been uploaded. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/documents-id.yaml b/packages/api/openapi/paths/documents-id.yaml new file mode 100644 index 0000000..62e0bdd --- /dev/null +++ b/packages/api/openapi/paths/documents-id.yaml @@ -0,0 +1,39 @@ +parameters: + - name: id + in: path + required: true + description: Document primary key. + schema: + type: string + format: uuid + example: dddddddd-dddd-4ddd-8ddd-dddddddddddd +get: + tags: [Documents] + summary: Get a document + description: Returns document metadata and a presigned GET URL. + operationId: get-api-documents-id + responses: + "200": + description: Document with download URL. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Document + "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: Document not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/gl-accounts-id.yaml b/packages/api/openapi/paths/gl-accounts-id.yaml new file mode 100644 index 0000000..c49d96f --- /dev/null +++ b/packages/api/openapi/paths/gl-accounts-id.yaml @@ -0,0 +1,82 @@ +parameters: + - name: id + in: path + required: true + description: GL account primary key. + schema: + type: string + format: uuid + example: 66666666-6666-4666-8666-666666666666 +get: + tags: [Master data] + summary: Get a GL account + description: Returns one GL account by id. + operationId: get-api-gl-accounts-id + responses: + "200": + description: GL account. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/GlAccount + "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: GL account not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +patch: + tags: [Master data] + summary: Update a GL account + description: Admin-only patch. Requires admin:settings. + operationId: patch-api-gl-accounts-id + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + example: "6100" + name: + type: string + example: Facilities Expense + responses: + "200": + description: Updated GL account. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/GlAccount + "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: GL account not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/gl-accounts.yaml b/packages/api/openapi/paths/gl-accounts.yaml new file mode 100644 index 0000000..8dfb21c --- /dev/null +++ b/packages/api/openapi/paths/gl-accounts.yaml @@ -0,0 +1,68 @@ +get: + tags: [Master data] + summary: List GL accounts + description: Returns every GL account row. + operationId: get-api-gl-accounts + responses: + "200": + description: GL account list. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/GlAccount + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +post: + tags: [Master data] + summary: Create a GL account + description: Admin-only insert. Requires admin:settings. + operationId: post-api-gl-accounts + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code, name] + properties: + code: + type: string + example: "6200" + name: + type: string + example: Utilities + responses: + "201": + description: Created GL account. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/GlAccount + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + 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/health.yaml b/packages/api/openapi/paths/health.yaml index a53bd7c..5d08746 100644 --- a/packages/api/openapi/paths/health.yaml +++ b/packages/api/openapi/paths/health.yaml @@ -1,33 +1,17 @@ get: tags: - Health - summary: Check API and database liveness - description: Returns ok when the process can ping the configured database. - operationId: get-health + summary: Check API process liveness + description: Returns stage and build SHA. ALB target-group probes call this without auth or a database ping. + operationId: get-api-health security: [] responses: "200": - description: API process is healthy and the database answered. + description: API process is up. content: application/json: schema: $ref: ../components/schemas.yaml#/HealthResponse example: - status: ok - database: up - "400": - description: Bad request. - content: - application/json: - schema: - $ref: ../components/schemas.yaml#/Error - example: - error: Bad request. - "503": - description: Database ping failed. - content: - application/json: - schema: - $ref: ../components/schemas.yaml#/Error - example: - error: Database is unavailable. + stage: local + sha: deadbeef 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/openapi/paths/invoices-id-documents.yaml b/packages/api/openapi/paths/invoices-id-documents.yaml new file mode 100644 index 0000000..039a40b --- /dev/null +++ b/packages/api/openapi/paths/invoices-id-documents.yaml @@ -0,0 +1,53 @@ +parameters: + - name: id + in: path + required: true + description: Invoice primary key. + schema: + type: string + format: uuid + example: 88888888-8888-4888-8888-888888888888 +post: + tags: [Documents] + summary: Presign a document upload + description: Creates a document row and returns a MinIO or S3 presigned PUT URL. + operationId: post-api-invoices-id-documents + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [fileName] + properties: + fileName: + type: string + example: inv-1001.pdf + contentType: + type: string + example: application/pdf + responses: + "201": + description: Document row with upload URL. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Document + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + 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-lines.yaml b/packages/api/openapi/paths/invoices-id-lines.yaml new file mode 100644 index 0000000..af59da5 --- /dev/null +++ b/packages/api/openapi/paths/invoices-id-lines.yaml @@ -0,0 +1,123 @@ +parameters: + - name: id + in: path + required: true + description: Invoice primary key. + schema: + type: string + format: uuid + example: 88888888-8888-4888-8888-888888888888 +post: + tags: [Invoices] + summary: Add an invoice line + description: Appends one coding line. Sum is checked on replace, not on this add. + operationId: post-api-invoices-id-lines + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [description, amount] + properties: + description: + type: string + example: Extra coding + amount: + type: string + example: "25.00" + glAccountId: + type: string + format: uuid + example: 66666666-6666-4666-8666-666666666666 + departmentId: + type: string + format: uuid + example: 77777777-7777-4777-8777-777777777777 + responses: + "201": + description: Created line. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/InvoiceLine + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Invoice not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +put: + tags: [Invoices] + summary: Replace invoice lines + description: Replaces every coding line. The amounts must sum to the invoice amount. + operationId: put-api-invoices-id-lines + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + type: object + required: [description, amount] + properties: + description: + type: string + example: Labor + amount: + type: string + example: "1250.00" + glAccountId: + type: string + format: uuid + departmentId: + type: string + format: uuid + responses: + "200": + description: Replaced lines. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/InvoiceLine + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + 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.yaml b/packages/api/openapi/paths/invoices-id.yaml new file mode 100644 index 0000000..ca7de24 --- /dev/null +++ b/packages/api/openapi/paths/invoices-id.yaml @@ -0,0 +1,107 @@ +parameters: + - name: id + in: path + required: true + description: Invoice primary key. + schema: + type: string + format: uuid + example: 88888888-8888-4888-8888-888888888888 +get: + tags: [Invoices] + summary: Get an invoice + description: Returns one invoice and its coding lines. + operationId: get-api-invoices-id + responses: + "200": + description: Invoice. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Invoice + "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 +patch: + tags: [Invoices] + summary: Update an invoice + description: Patch header fields. Duplicate active vendor plus invoice number is a conflict. + operationId: patch-api-invoices-id + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + vendorId: + type: string + format: uuid + example: 55555555-5555-4555-8555-555555555555 + invoiceNumber: + type: string + example: INV-1001 + amount: + type: string + example: "1250.00" + dueDate: + type: string + example: "2026-09-01" + status: + type: string + enum: [void] + description: The only status PATCH may set. Approval and payment statuses go through decision routes. + example: void + paymentMethod: + type: string + enum: [check, ach] + example: ach + memo: + type: string + example: Updated memo + responses: + "200": + description: Updated invoice. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Invoice + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Invoice not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "409": + description: Active vendor and invoice number already exist. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/invoices.yaml b/packages/api/openapi/paths/invoices.yaml new file mode 100644 index 0000000..1e45e53 --- /dev/null +++ b/packages/api/openapi/paths/invoices.yaml @@ -0,0 +1,106 @@ +get: + tags: [Invoices] + summary: List invoices + description: Returns invoices with their coding lines. + operationId: get-api-invoices + responses: + "200": + description: Invoice list. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/Invoice + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +post: + tags: [Invoices] + summary: Create an invoice + description: Inserts an invoice. Line amounts must sum to amount when lines are sent. Duplicate active vendor plus invoice number is a conflict. + operationId: post-api-invoices + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [vendorId, invoiceNumber, amount, dueDate] + properties: + vendorId: + type: string + format: uuid + example: 55555555-5555-4555-8555-555555555555 + invoiceNumber: + type: string + example: INV-2002 + amount: + type: string + example: "100.00" + dueDate: + type: string + example: "2026-10-01" + paymentMethod: + type: string + enum: [check, ach] + example: check + memo: + type: string + example: Harbor repair + lines: + type: array + items: + type: object + required: [description, amount] + properties: + description: + type: string + example: Labor + amount: + type: string + example: "100.00" + glAccountId: + type: string + format: uuid + departmentId: + type: string + format: uuid + responses: + "201": + description: Created invoice. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Invoice + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "409": + description: Active vendor and invoice number already exist. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/me.yaml b/packages/api/openapi/paths/me.yaml index f98682f..c8fe5d5 100644 --- a/packages/api/openapi/paths/me.yaml +++ b/packages/api/openapi/paths/me.yaml @@ -5,7 +5,7 @@ get: description: Upserts the caller into users on first request and returns the stored profile used by the SPA session smoke path. operationId: get-api-me security: - - bearerAuth: [] + - cookieAuth: [] responses: "200": description: Authenticated user profile. @@ -19,13 +19,16 @@ get: name: Dev Admin role: admin "401": - description: Missing or invalid bearer token. + description: Missing or invalid session cookie. content: application/json: schema: $ref: ../components/schemas.yaml#/Error example: - error: Missing or invalid Authorization header. + error: + code: UNAUTHENTICATED + message: Missing id token. + correlationId: 11111111-1111-4111-8111-111111111111 "409": description: Email is already linked to a different Cognito subject. content: @@ -33,7 +36,10 @@ get: schema: $ref: ../components/schemas.yaml#/Error example: - error: Email admin@seahavenind.com is already linked to a different identity. + error: + code: CONFLICT + message: Email admin@seahavenind.com is already linked to a different identity. + correlationId: 11111111-1111-4111-8111-111111111111 "404": description: Not found. content: @@ -41,4 +47,7 @@ get: schema: $ref: ../components/schemas.yaml#/Error example: - error: Not found. + error: + code: NOT_FOUND + message: Not found. + correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/ready.yaml b/packages/api/openapi/paths/ready.yaml new file mode 100644 index 0000000..b64c207 --- /dev/null +++ b/packages/api/openapi/paths/ready.yaml @@ -0,0 +1,28 @@ +get: + tags: + - Health + summary: Check API database readiness + description: Pings the configured database. Not the ALB target. Optional for operators and deploy verify. + operationId: get-api-ready + security: [] + responses: + "200": + description: Database answered. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/ReadyResponse + example: + status: ok + database: up + "503": + description: Database ping failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: DATABASE_UNAVAILABLE + message: Database is unavailable. + correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/users-id.yaml b/packages/api/openapi/paths/users-id.yaml new file mode 100644 index 0000000..0db0ea1 --- /dev/null +++ b/packages/api/openapi/paths/users-id.yaml @@ -0,0 +1,81 @@ +parameters: + - name: id + in: path + required: true + description: User primary key. + schema: + type: string + format: uuid + example: 11111111-1111-4111-8111-111111111111 +get: + tags: [Master data] + summary: Get a user + description: Returns one user by id. + operationId: get-api-users-id + responses: + "200": + description: User. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/User + "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: User not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +patch: + tags: [Master data] + summary: Update a user role + description: Admin-only role update. Does not rebind email across Cognito subjects. + operationId: patch-api-users-id + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [role] + properties: + role: + type: string + enum: [admin, ap_processor, approver, viewer] + example: approver + responses: + "200": + description: Updated user. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/User + "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: User not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/users.yaml b/packages/api/openapi/paths/users.yaml new file mode 100644 index 0000000..1cd0665 --- /dev/null +++ b/packages/api/openapi/paths/users.yaml @@ -0,0 +1,24 @@ +get: + tags: [Master data] + summary: List users + description: Returns every user profile. Identity upsert remains sub-keyed. + operationId: get-api-users + responses: + "200": + description: User list. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/User + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/vendors-id.yaml b/packages/api/openapi/paths/vendors-id.yaml new file mode 100644 index 0000000..593afcd --- /dev/null +++ b/packages/api/openapi/paths/vendors-id.yaml @@ -0,0 +1,86 @@ +parameters: + - name: id + in: path + required: true + description: Vendor primary key. + schema: + type: string + format: uuid + example: 55555555-5555-4555-8555-555555555555 +get: + tags: [Master data] + summary: Get a vendor + description: Returns one vendor by id. + operationId: get-api-vendors-id + responses: + "200": + description: Vendor. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Vendor + "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: Vendor not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +patch: + tags: [Master data] + summary: Update a vendor + description: Admin-only patch. Requires admin:settings. + operationId: patch-api-vendors-id + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + example: Harbor Maintenance LLC + email: + type: string + example: billing@harbor.example + defaultPaymentMethod: + type: string + enum: [check, ach] + example: check + responses: + "200": + description: Updated vendor. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Vendor + "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: Vendor not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/vendors.yaml b/packages/api/openapi/paths/vendors.yaml new file mode 100644 index 0000000..4db6fd3 --- /dev/null +++ b/packages/api/openapi/paths/vendors.yaml @@ -0,0 +1,72 @@ +get: + tags: [Master data] + summary: List vendors + description: Returns every vendor row. + operationId: get-api-vendors + responses: + "200": + description: Vendor list. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/Vendor + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +post: + tags: [Master data] + summary: Create a vendor + description: Admin-only insert. Requires admin:settings. + operationId: post-api-vendors + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + example: Harbor Maintenance + email: + type: string + example: billing@harbor.example + defaultPaymentMethod: + type: string + enum: [check, ach] + example: ach + responses: + "201": + description: Created vendor. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Vendor + "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 + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/package.json b/packages/api/package.json index fc61bf9..9f7b478 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -31,6 +31,8 @@ }, "dependencies": { "@aws-sdk/client-rds-data": "^3.1135.0", + "@aws-sdk/client-s3": "^3.1137.0", + "@aws-sdk/s3-request-presigner": "^3.1137.0", "@hono/node-server": "^2.1.1", "@seahaven-ap/shared": "*", "drizzle-orm": "^0.45.2", diff --git a/packages/api/src/app.test.ts b/packages/api/src/app.test.ts index 27e1de1..8e43314 100644 --- a/packages/api/src/app.test.ts +++ b/packages/api/src/app.test.ts @@ -3,6 +3,7 @@ import { createApp } from "./app.js"; import type { Db } from "./db/client.js"; import { loadEnv } from "./env.js"; import type { AuthUser } from "./auth/upsert-user.js"; +import type { ErrorEnvelope } from "./http.js"; const sampleUser: AuthUser = { id: "11111111-1111-4111-8111-111111111111", @@ -56,6 +57,14 @@ function createTestDb(options?: { pingFails?: boolean }): Db { }; } +function expectEnvelope(body: unknown, code: string, message: string) { + const envelope = body as ErrorEnvelope; + expect(envelope.error.code).toBe(code); + expect(envelope.error.message).toBe(message); + expect(envelope.error.correlationId).toEqual(expect.any(String)); + expect(envelope.error.correlationId.length).toBeGreaterThan(0); +} + describe("createApp smoke routes", () => { const env = loadEnv({ NODE_ENV: "test", @@ -66,18 +75,25 @@ describe("createApp smoke routes", () => { DEV_AUTH_ROLE: sampleUser.role, }); - it("GET /health returns ok when the database pings", async () => { + it("GET /api/health returns stage and sha without auth or a database ping", async () => { + const app = createApp(env, createTestDb({ pingFails: true })); + const response = await app.request("/api/health"); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ stage: "local", sha: "unknown" }); + }); + + it("GET /api/ready returns ok when the database pings", async () => { const app = createApp(env, createTestDb()); - const response = await app.request("/health"); + const response = await app.request("/api/ready"); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ status: "ok", database: "up" }); }); - it("GET /health returns error payload when the database is down", async () => { + it("GET /api/ready returns the error envelope when the database is down", async () => { const app = createApp(env, createTestDb({ pingFails: true })); - const response = await app.request("/health"); + const response = await app.request("/api/ready"); expect(response.status).toBe(503); - await expect(response.json()).resolves.toEqual({ error: "Database is unavailable." }); + expectEnvelope(await response.json(), "DATABASE_UNAVAILABLE", "Database is unavailable."); }); it("GET /api/me returns the upserted caller under DEV_AUTH_BYPASS", async () => { @@ -92,7 +108,7 @@ describe("createApp smoke routes", () => { }); }); - it("GET /api/me rejects missing bearer token when bypass is off", async () => { + it("GET /api/me rejects missing session cookies when bypass is off", async () => { const secureEnv = loadEnv({ NODE_ENV: "test", DEV_AUTH_BYPASS: "false", @@ -102,8 +118,160 @@ describe("createApp smoke routes", () => { const app = createApp(secureEnv, createTestDb()); const response = await app.request("/api/me"); expect(response.status).toBe(401); - await expect(response.json()).resolves.toEqual({ - error: "Missing or invalid Authorization header.", + expectEnvelope(await response.json(), "UNAUTHENTICATED", "Missing id token."); + }); + + it("unknown paths return the 404 error envelope", async () => { + const app = createApp(env, createTestDb()); + const response = await app.request("/does-not-exist"); + expect(response.status).toBe(404); + expectEnvelope(await response.json(), "NOT_FOUND", "Not found."); + }); + + it("unhandled throws return the 500 error envelope", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const app = createApp(env, createTestDb()); + app.get("/explode", () => { + throw new Error("boom"); }); + const response = await app.request("/explode"); + expect(response.status).toBe(500); + expectEnvelope(await response.json(), "INTERNAL_ERROR", "Internal server error."); + errorSpy.mockRestore(); + }); +}); + +describe("cookie BFF", () => { + function setCookies(response: Response): string[] { + const getSetCookie = response.headers.getSetCookie?.bind(response.headers); + if (getSetCookie) return getSetCookie(); + const single = response.headers.get("set-cookie"); + return single ? [single] : []; + } + + it("GET /api/auth/login sets __Host-ap_oauth in a production-like NODE_ENV", async () => { + const env = loadEnv({ + NODE_ENV: "production", + STAGE: "dev", + COGNITO_ISSUER: "https://cognito-idp.us-east-1.amazonaws.com/test", + COGNITO_AUDIENCE: "test-audience", + COGNITO_DOMAIN: "auth.dev.example", + APP_ORIGIN: "https://d111111abcdef8.cloudfront.net", + }); + const app = createApp(env, createTestDb()); + const response = await app.request("/api/auth/login"); + expect(response.status).toBe(302); + const cookies = setCookies(response); + const oauth = cookies.find((item) => item.startsWith("__Host-ap_oauth=")); + expect(oauth).toBeDefined(); + expect(oauth).toContain("HttpOnly"); + expect(oauth).toMatch(/(?:^|; )Secure(?:;|$)/); + expect(oauth).not.toMatch(/Domain=/i); + expect(response.headers.get("location")).toContain("https://auth.dev.example/oauth2/authorize"); + }); + + it("GET /api/auth/login omits __Host- and Secure on the local bypass path", async () => { + const env = loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "true", + COGNITO_ISSUER: "https://cognito-idp.us-east-1.amazonaws.com/test", + COGNITO_AUDIENCE: "test-audience", + COGNITO_DOMAIN: "auth.dev.example", + }); + const app = createApp(env, createTestDb()); + const response = await app.request("/api/auth/login"); + expect(response.status).toBe(302); + const cookies = setCookies(response); + const oauth = cookies.find((item) => item.startsWith("ap_oauth=")); + expect(oauth).toBeDefined(); + expect(oauth).toContain("HttpOnly"); + expect(oauth).not.toMatch(/(?:^|; )Secure(?:;|$)/); + expect(cookies.some((item) => item.startsWith("__Host-ap_oauth="))).toBe(false); + }); + + it("GET /api/auth/callback sets __Host-ap_* token cookies", async () => { + const env = loadEnv({ + NODE_ENV: "production", + STAGE: "dev", + COGNITO_ISSUER: "https://cognito-idp.us-east-1.amazonaws.com/test", + COGNITO_AUDIENCE: "test-audience", + COGNITO_DOMAIN: "auth.dev.example", + APP_ORIGIN: "https://d111111abcdef8.cloudfront.net", + }); + const login = await createApp(env, createTestDb()).request( + "/api/auth/login?returnTo=/invoices", + ); + const oauthCookie = setCookies(login).find((item) => item.startsWith("__Host-ap_oauth=")); + expect(oauthCookie).toBeDefined(); + const location = new URL(login.headers.get("location") ?? ""); + const state = location.searchParams.get("state") ?? ""; + const app = createApp(env, createTestDb(), { + tokens: { + exchangeCode: async () => ({ + accessToken: "at", + idToken: "it", + refreshToken: "rt", + }), + refresh: async () => ({ accessToken: "at", idToken: "it", refreshToken: "rt" }), + revoke: async () => undefined, + }, + }); + const response = await app.request(`/api/auth/callback?code=abc&state=${state}`, { + headers: { cookie: oauthCookie!.split(";")[0] }, + }); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/invoices"); + const cookies = setCookies(response); + expect( + cookies.some((item) => item.startsWith("__Host-ap_at=") && item.includes("Secure")), + ).toBe(true); + expect(cookies.some((item) => item.startsWith("__Host-ap_it="))).toBe(true); + expect(cookies.some((item) => item.startsWith("__Host-ap_rt="))).toBe(true); + }); + + it("GET /api/me succeeds with an id cookie and fails without", async () => { + const env = loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "false", + COGNITO_ISSUER: "https://cognito-idp.us-east-1.amazonaws.com/test", + COGNITO_AUDIENCE: "test-audience", + }); + const app = createApp(env, createTestDb(), { + verifyToken: async () => ({ + sub: sampleUser.cognitoSub, + email: sampleUser.email, + name: sampleUser.name, + aud: "test-audience", + token_use: "id", + }), + }); + const missing = await app.request("/api/me"); + expect(missing.status).toBe(401); + const ok = await app.request("/api/me", { headers: { cookie: "ap_it=fake-id-token" } }); + expect(ok.status).toBe(200); + await expect(ok.json()).resolves.toEqual({ + id: sampleUser.id, + email: sampleUser.email, + name: sampleUser.name, + role: sampleUser.role, + }); + }); + + it("returns 403 when origin-verify is missing on non-health /api", async () => { + const env = loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "true", + ORIGIN_VERIFY_SECRET: "origin-secret", + }); + const app = createApp(env, createTestDb()); + const health = await app.request("/api/health"); + expect(health.status).toBe(200); + const me = await app.request("/api/me"); + expect(me.status).toBe(403); + expectEnvelope(await me.json(), "FORBIDDEN", "Origin is not allowed."); + const allowed = await app.request("/api/me", { + headers: { "x-origin-verify": "origin-secret" }, + }); + expect(allowed.status).toBe(200); }); }); diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 340d45c..a4a95a7 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -1,25 +1,68 @@ import { Hono } from "hono"; import type { ApiEnv } from "./env.js"; import type { Db } from "./db/client.js"; -import { createAuthMiddleware, type AppBindings } from "./auth/middleware.js"; +import { createAuthMiddleware, type AppBindings, type AuthDeps } from "./auth/middleware.js"; import { createHealthRoutes } from "./routes/health.js"; import { createMeRoutes } from "./routes/me.js"; +import { createAuthRoutes } from "./routes/auth.js"; +import { createVendorRoutes } from "./routes/vendors.js"; +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 { ApiError, errorJson } from "./http.js"; +import { cloudFrontOriginAllowed } from "./auth/origin-verify.js"; +import { csrfAllowed, isMutating } from "./auth/oauth.js"; +import type { CognitoTokenClient } from "./auth/cognito.js"; -export function createApp(env: ApiEnv, handle: Db) { +export type AppDeps = AuthDeps & { + tokens?: CognitoTokenClient; + documents?: DocumentsStore; +}; + +export function createApp(env: ApiEnv, handle: Db, deps: AppDeps = {}) { const app = new Hono(); - const auth = createAuthMiddleware(env, handle); - - app.route("/", createHealthRoutes(handle)); + const auth = createAuthMiddleware(env, handle, deps); const api = new Hono(); + api.use("*", async (c, next) => { + const path = new URL(c.req.url).pathname; + if (path === "/api/health") { + await next(); + return; + } + if (!cloudFrontOriginAllowed(c, env.originVerifySecret || undefined)) { + return errorJson(c, 403, "FORBIDDEN", "Origin is not allowed."); + } + await next(); + }); + api.use("*", async (c, next) => { + if (isMutating(c.req.method) && !csrfAllowed(c, env)) { + return errorJson(c, 403, "FORBIDDEN", "Origin is not allowed."); + } + await next(); + }); api.use("*", auth); + api.route("/", createHealthRoutes(env, handle)); + api.route("/", createAuthRoutes(env, deps)); api.route("/", createMeRoutes()); + api.route("/", createVendorRoutes(handle)); + api.route("/", createGlAccountRoutes(handle)); + 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) => c.json({ error: "Not found." }, 404)); + app.notFound((c) => errorJson(c, 404, "NOT_FOUND", "Not found.")); app.onError((error, c) => { + if (error instanceof ApiError) { + return errorJson(c, error.status, error.code, error.message); + } console.error(error); - return c.json({ error: "Internal server error." }, 500); + return errorJson(c, 500, "INTERNAL_ERROR", "Internal server error."); }); return app; diff --git a/packages/api/src/approvals.ts b/packages/api/src/approvals.ts new file mode 100644 index 0000000..89afeb6 --- /dev/null +++ b/packages/api/src/approvals.ts @@ -0,0 +1,95 @@ +import { eq } from "drizzle-orm"; +import type { DbHandle } 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: DbHandle, + invoiceId: string, + actorUserId: string, + message: string, +) { + await handle.db.insert(activityLog).values({ invoiceId, actorUserId, message }).returning(); +} + +export async function applyMatchingPolicy( + handle: DbHandle, + 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: DbHandle, invoiceId: string): Promise { + const rows = await rowsOf(handle, approvalSteps); + return rows.filter((row) => row.invoiceId === invoiceId && row.status === "pending"); +} + +export async function closePendingSteps( + handle: DbHandle, + invoiceId: string, + actorUserId: string | null, +): Promise { + const pending = await remainingPending(handle, invoiceId); + const actedAt = new Date(); + for (const step of pending) { + await handle.db + .update(approvalSteps) + .set({ + id: step.id, + status: "skipped", + actedByUserId: actorUserId, + actedAt, + }) + .where(eq(approvalSteps.id, step.id)) + .returning(); + } +} diff --git a/packages/api/src/auth/cognito.ts b/packages/api/src/auth/cognito.ts new file mode 100644 index 0000000..361bece --- /dev/null +++ b/packages/api/src/auth/cognito.ts @@ -0,0 +1,104 @@ +export type TokenSet = { + accessToken: string; + idToken: string; + refreshToken?: string; +}; + +export type CognitoTokenClient = { + exchangeCode(input: { code: string; verifier: string; redirectUri: string }): Promise; + refresh(refreshToken: string): Promise; + revoke(refreshToken: string): Promise; +}; + +export function hostedOrigin(domain: string): string { + const trimmed = domain.trim().replace(/\/$/, ""); + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return `https://${trimmed}`; +} + +export function authorizeUrl(input: { + domain: string; + clientId: string; + redirectUri: string; + state: string; + challenge: string; +}): string { + const url = new URL(`${hostedOrigin(input.domain)}/oauth2/authorize`); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", input.clientId); + url.searchParams.set("redirect_uri", input.redirectUri); + url.searchParams.set("scope", "openid email profile"); + url.searchParams.set("state", input.state); + url.searchParams.set("code_challenge", input.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("identity_provider", "Google"); + return url.toString(); +} + +export function callbackRedirectUri(appOrigin: string): string { + return `${appOrigin.replace(/\/$/, "")}/api/auth/callback`; +} + +export function createCognitoTokenClient( + config: { cognitoAudience: string; cognitoDomain: string }, + fetchFn: typeof fetch = fetch, +): CognitoTokenClient { + return { + exchangeCode(input) { + return tokenRequest(config, fetchFn, { + grant_type: "authorization_code", + code: input.code, + code_verifier: input.verifier, + redirect_uri: input.redirectUri, + }); + }, + async refresh(refreshToken) { + return tokenRequest(config, fetchFn, { + grant_type: "refresh_token", + refresh_token: refreshToken, + }); + }, + async revoke(refreshToken) { + const domain = config.cognitoDomain; + const clientId = config.cognitoAudience; + if (!domain || !clientId) throw new Error("Cognito domain and client id are required"); + const response = await fetchFn(`${hostedOrigin(domain)}/oauth2/revoke`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: refreshToken, client_id: clientId }).toString(), + }); + if (!response.ok && response.status !== 200) { + throw new Error(`revoke failed (${response.status})`); + } + }, + }; +} + +async function tokenRequest( + config: { cognitoAudience: string; cognitoDomain: string }, + fetchFn: typeof fetch, + fields: Record, +): Promise { + const domain = config.cognitoDomain; + const clientId = config.cognitoAudience; + if (!domain || !clientId) throw new Error("Cognito domain and client id are required"); + const response = await fetchFn(`${hostedOrigin(domain)}/oauth2/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ client_id: clientId, ...fields }).toString(), + }); + if (!response.ok) throw new Error(`token request failed (${response.status})`); + const body = (await response.json()) as { + access_token?: unknown; + id_token?: unknown; + refresh_token?: unknown; + }; + if (typeof body.access_token !== "string" || typeof body.id_token !== "string") { + throw new Error("token response missing tokens"); + } + return { + accessToken: body.access_token, + idToken: body.id_token, + refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : undefined, + }; +} diff --git a/packages/api/src/auth/cookies.test.ts b/packages/api/src/auth/cookies.test.ts new file mode 100644 index 0000000..c237ef9 --- /dev/null +++ b/packages/api/src/auth/cookies.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { + ACCESS_MAX_AGE_SEC, + COOKIE_ACCESS, + COOKIE_ID, + COOKIE_OAUTH, + COOKIE_REFRESH, + COOKIE_SESSION_HINT, + REFRESH_MAX_AGE_SEC, + clearCookie, + clearedSessionCookies, + cookieNames, + parseCookies, + serializeCookie, + serializeOauthCookie, + sessionCookieValue, + tokenCookies, +} from "./cookies.js"; + +const host = cookieNames("dev"); +const local = cookieNames("local"); + +describe("auth cookies", () => { + it("prefixes deployed cookies with __Host- and keeps local names unprefixed", () => { + expect(host).toEqual({ + access: `__Host-${COOKIE_ACCESS}`, + id: `__Host-${COOKIE_ID}`, + refresh: `__Host-${COOKIE_REFRESH}`, + oauth: `__Host-${COOKIE_OAUTH}`, + hint: `__Host-${COOKIE_SESSION_HINT}`, + }); + expect(local).toEqual({ + access: COOKIE_ACCESS, + id: COOKIE_ID, + refresh: COOKIE_REFRESH, + oauth: COOKIE_OAUTH, + hint: COOKIE_SESSION_HINT, + }); + }); + + it("sets HttpOnly, SameSite=Lax, Path=/, no Domain, and Secure outside local", () => { + const cookie = serializeCookie(host.access, "tok", { + maxAge: ACCESS_MAX_AGE_SEC, + stage: "dev", + }); + expect(cookie.startsWith(`${host.access}=`)).toBe(true); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Lax"); + expect(cookie).toContain("Path=/"); + expect(cookie).toMatch(/(?:^|; )Secure(?:;|$)/); + expect(cookie).not.toMatch(/Domain=/i); + expect(cookie).toContain(`Max-Age=${ACCESS_MAX_AGE_SEC}`); + }); + + it("omits Secure on local and scopes refresh to /api/auth", () => { + const cookie = serializeCookie(local.access, "tok", { + maxAge: ACCESS_MAX_AGE_SEC, + stage: "local", + }); + expect(cookie).toContain("HttpOnly"); + expect(cookie).not.toMatch(/(?:^|; )Secure(?:;|$)/); + expect(cookie).not.toMatch(/Domain=/i); + + const refresh = tokenCookies( + { accessToken: "a", idToken: "i", refreshToken: "r" }, + "local", + ).find((item) => item.startsWith(`${local.refresh}=`)); + expect(refresh).toContain("Path=/api/auth"); + expect(refresh).not.toMatch(/(?:^|; )Secure(?:;|$)/); + }); + + it("sets a non-HttpOnly session hint for 8h", () => { + const cookies = tokenCookies({ accessToken: "a", idToken: "i", refreshToken: "r" }, "dev"); + const hint = cookies.find((item) => item.startsWith(`${host.hint}=`)); + expect(hint).toContain(`${host.hint}=1`); + expect(hint).toContain(`Max-Age=${REFRESH_MAX_AGE_SEC}`); + expect(hint).not.toContain("HttpOnly"); + expect(hint).toContain("SameSite=Lax"); + expect(hint).toMatch(/(?:^|; )Secure(?:;|$)/); + }); + + it("ignores unprefixed session cookies on deployed stages", () => { + expect( + sessionCookieValue({ headers: { cookie: `${COOKIE_ACCESS}=legacy` } }, "access", "dev"), + ).toBeUndefined(); + expect( + sessionCookieValue( + { headers: { cookie: `${host.access}=host; ${COOKIE_ACCESS}=legacy` } }, + "access", + "dev", + ), + ).toBe("host"); + }); + + it("reads unprefixed session cookies only on local", () => { + expect( + sessionCookieValue({ headers: { cookie: `${COOKIE_ACCESS}=local` } }, "access", "local"), + ).toBe("local"); + }); + + it("parses Cookie headers and keeps the first duplicate", () => { + expect( + parseCookies({ + headers: { cookie: `${host.access}=first; ${host.access}=second` }, + }), + ).toEqual({ [host.access]: "first" }); + }); + + it("clears cookies with Max-Age=0 and the same host-only attributes", () => { + const cookie = clearCookie(host.access, "dev"); + expect(cookie).toContain("Max-Age=0"); + expect(cookie).toContain("HttpOnly"); + expect(cookie).not.toMatch(/Domain=/i); + expect(cookie).toMatch(/(?:^|; )Secure(?:;|$)/); + }); + + it("clears both __Host- and legacy ap_* cookies on deployed stages", () => { + const cookies = clearedSessionCookies("dev"); + expect( + cookies.some((item) => item.startsWith(`${host.refresh}=`) && item.includes("Max-Age=0")), + ).toBe(true); + expect( + cookies.some( + (item) => + item.startsWith(`${COOKIE_REFRESH}=`) && + item.includes("Max-Age=0") && + item.includes("Path=/"), + ), + ).toBe(true); + }); + + it("encodes oauth state without a Domain attribute", () => { + const cookie = serializeOauthCookie({ state: "st", verifier: "ver", returnTo: "/" }, "dev"); + expect(cookie.startsWith(`${host.oauth}=`)).toBe(true); + expect(cookie).toContain("HttpOnly"); + expect(cookie).not.toMatch(/Domain=/i); + }); +}); diff --git a/packages/api/src/auth/cookies.ts b/packages/api/src/auth/cookies.ts new file mode 100644 index 0000000..e04d460 --- /dev/null +++ b/packages/api/src/auth/cookies.ts @@ -0,0 +1,248 @@ +export const COOKIE_ACCESS = "ap_at"; +export const COOKIE_ID = "ap_it"; +export const COOKIE_REFRESH = "ap_rt"; +export const COOKIE_OAUTH = "ap_oauth"; +export const COOKIE_SESSION_HINT = "ap_sess"; + +export const ACCESS_MAX_AGE_SEC = 60 * 60; +export const REFRESH_MAX_AGE_SEC = 8 * 60 * 60; +export const OAUTH_MAX_AGE_SEC = 10 * 60; + +export type CookieEvent = { + cookies?: string[]; + headers?: Record; +}; + +export type OauthCookie = { + state: string; + verifier: string; + returnTo: string; +}; + +export type CookieNames = { + access: string; + id: string; + refresh: string; + oauth: string; + hint: string; +}; + +export type SessionHint = { + displayName: string; + email: string; +}; + +export function cookieNames(stage: string): CookieNames { + const prefix = stage === "local" ? "" : "__Host-"; + return { + access: `${prefix}${COOKIE_ACCESS}`, + id: `${prefix}${COOKIE_ID}`, + refresh: `${prefix}${COOKIE_REFRESH}`, + oauth: `${prefix}${COOKIE_OAUTH}`, + hint: `${prefix}${COOKIE_SESSION_HINT}`, + }; +} + +export function parseCookies(event: CookieEvent): Record { + const parsed: Record = {}; + for (const part of cookieParts(event)) { + const eq = part.indexOf("="); + if (eq <= 0) continue; + const name = part.slice(0, eq).trim(); + const value = part.slice(eq + 1).trim(); + if (!name) continue; + if (Object.prototype.hasOwnProperty.call(parsed, name)) continue; + parsed[name] = decodeCookieValue(value); + } + return parsed; +} + +export function cookieValue(event: CookieEvent, name: string): string | undefined { + const value = parseCookies(event)[name]; + return value ? value : undefined; +} + +export function sessionCookieValue( + event: CookieEvent, + kind: keyof CookieNames, + stage: string, +): string | undefined { + return cookieValue(event, cookieNames(stage)[kind]); +} + +export function serializeCookie( + name: string, + value: string, + options: { maxAge: number; stage: string; path?: string; httpOnly?: boolean }, +): string { + const hostPrefixed = name.startsWith("__Host-"); + const path = hostPrefixed ? "/" : (options.path ?? "/"); + const parts = [ + `${name}=${encodeURIComponent(value)}`, + `Path=${path}`, + "SameSite=Lax", + `Max-Age=${options.maxAge}`, + ]; + if (options.httpOnly !== false) parts.splice(2, 0, "HttpOnly"); + if (hostPrefixed || options.stage !== "local") parts.push("Secure"); + return parts.join("; "); +} + +export function clearCookie( + name: string, + stage: string, + options: { httpOnly?: boolean } = {}, +): string { + return serializeCookie(name, "", { + maxAge: 0, + stage, + path: cookiePath(name), + httpOnly: options.httpOnly, + }); +} + +export function tokenCookies( + tokens: { accessToken: string; idToken: string; refreshToken?: string }, + stage: string, +): string[] { + const names = cookieNames(stage); + const cookies = [ + serializeCookie(names.access, tokens.accessToken, { maxAge: ACCESS_MAX_AGE_SEC, stage }), + serializeCookie(names.id, tokens.idToken, { maxAge: ACCESS_MAX_AGE_SEC, stage }), + ]; + if (tokens.refreshToken) { + cookies.push( + serializeCookie(names.refresh, tokens.refreshToken, { + maxAge: REFRESH_MAX_AGE_SEC, + stage, + path: cookiePath(names.refresh), + }), + ); + } + cookies.push( + serializeCookie(names.hint, sessionHintValue(tokens.idToken), { + maxAge: REFRESH_MAX_AGE_SEC, + stage, + httpOnly: false, + }), + ); + cookies.push(clearCookie(names.oauth, stage)); + return cookies; +} + +export function clearedSessionCookies(stage: string): string[] { + const names = cookieNames(stage); + const cookies = [ + clearCookie(names.access, stage), + clearCookie(names.id, stage), + clearCookie(names.refresh, stage), + clearCookie(names.oauth, stage), + clearCookie(names.hint, stage, { httpOnly: false }), + ]; + if (stage !== "local") { + const legacy = cookieNames("local"); + cookies.push( + serializeCookie(legacy.access, "", { maxAge: 0, stage, path: "/" }), + serializeCookie(legacy.id, "", { maxAge: 0, stage, path: "/" }), + serializeCookie(legacy.refresh, "", { maxAge: 0, stage, path: "/" }), + serializeCookie(legacy.oauth, "", { maxAge: 0, stage, path: "/" }), + serializeCookie(legacy.hint, "", { maxAge: 0, stage, path: "/", httpOnly: false }), + ); + } + return cookies; +} + +export function serializeOauthCookie(payload: OauthCookie, stage: string): string { + const names = cookieNames(stage); + return serializeCookie(names.oauth, encodeOauth(payload), { maxAge: OAUTH_MAX_AGE_SEC, stage }); +} + +export function readOauthCookie(event: CookieEvent, stage: string): OauthCookie | undefined { + const raw = sessionCookieValue(event, "oauth", stage); + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.state !== "string" || + !parsed.state || + typeof parsed.verifier !== "string" || + !parsed.verifier || + typeof parsed.returnTo !== "string" + ) { + return undefined; + } + return { state: parsed.state, verifier: parsed.verifier, returnTo: parsed.returnTo }; + } catch { + return undefined; + } +} + +export function headerFrom(event: CookieEvent, name: string): string | undefined { + const headers = event.headers ?? {}; + const needle = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === needle) return value; + } + return undefined; +} + +export function cookieEventFromHeader(cookieHeader: string | undefined): CookieEvent { + return { headers: { cookie: cookieHeader } }; +} + +export function sessionHintFromIdToken(token: string): SessionHint | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + try { + const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record< + string, + unknown + >; + const email = + typeof claims.email === "string" && claims.email.includes("@") ? claims.email : ""; + if (!email) return null; + const displayName = + (typeof claims.name === "string" && claims.name) || + (typeof claims.given_name === "string" && claims.given_name) || + email; + return { email, displayName }; + } catch { + return null; + } +} + +function cookiePath(name: string): string { + if (name.startsWith("__Host-")) return "/"; + return name.endsWith(COOKIE_REFRESH) ? "/api/auth" : "/"; +} + +function sessionHintValue(idToken: string): string { + const hint = sessionHintFromIdToken(idToken); + return hint ? JSON.stringify(hint) : "1"; +} + +function cookieParts(event: CookieEvent): string[] { + const parts: string[] = []; + for (const cookie of event.cookies ?? []) { + parts.push(cookie); + } + const header = headerFrom(event, "cookie"); + if (header) { + for (const part of header.split(";")) { + if (part.trim()) parts.push(part); + } + } + return parts; +} + +function decodeCookieValue(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function encodeOauth(payload: OauthCookie): string { + return JSON.stringify(payload); +} diff --git a/packages/api/src/auth/middleware.ts b/packages/api/src/auth/middleware.ts index a832bcf..f5707aa 100644 --- a/packages/api/src/auth/middleware.ts +++ b/packages/api/src/auth/middleware.ts @@ -5,6 +5,9 @@ import { IdentityConflictError, upsertUserFromIdentity } from "./upsert-user.js" import type { ApiEnv, UserRole } from "../env.js"; import { isUserRole } from "../env.js"; import type { Db } from "../db/client.js"; +import { errorJson } from "../http.js"; +import { cookieEventFromHeader, sessionCookieValue } from "./cookies.js"; +import { cookieStage, isPublicRoute } from "./oauth.js"; export type AppVariables = { user: AuthUser; @@ -14,12 +17,19 @@ export type AppBindings = { Variables: AppVariables; }; -function roleFromClaims(claims: Record, fallback: UserRole): UserRole { +export type TokenVerifier = (token: string) => Promise; + +export type AuthDeps = { + verifyToken?: TokenVerifier; +}; + +function roleFromClaims(claims: Record): UserRole | undefined { const raw = (typeof claims["custom:role"] === "string" && claims["custom:role"]) || (typeof claims.role === "string" && claims.role) || - fallback; - return isUserRole(raw) ? raw : fallback; + undefined; + if (raw === undefined) return undefined; + return isUserRole(raw) ? raw : undefined; } function audienceMatches(payload: JWTPayload, expected: string): boolean { @@ -55,13 +65,31 @@ function identityFromPayload(payload: JWTPayload): { return { sub, email, name }; } -export function createAuthMiddleware(env: ApiEnv, handle: Db) { +export function createAuthMiddleware(env: ApiEnv, handle: Db, deps: AuthDeps = {}) { const jwks = env.cognitoIssuer.length > 0 ? createRemoteJWKSet(new URL(`${env.cognitoIssuer}/.well-known/jwks.json`)) : null; + const verifyToken: TokenVerifier = + deps.verifyToken ?? + (async (token) => { + if (!jwks) { + throw new Error("JWT verification is not configured."); + } + const { payload } = await jwtVerify(token, jwks, { + issuer: env.cognitoIssuer, + }); + return payload; + }); + return createMiddleware(async (c, next) => { + const path = new URL(c.req.url).pathname; + if (isPublicRoute(c.req.method, path)) { + await next(); + return; + } + if (env.devAuthBypass) { try { const user = await upsertUserFromIdentity(handle, { @@ -73,7 +101,7 @@ export function createAuthMiddleware(env: ApiEnv, handle: Db) { c.set("user", user); } catch (error) { if (error instanceof IdentityConflictError) { - return c.json({ error: error.message }, 409); + return errorJson(c, 409, "CONFLICT", error.message); } throw error; } @@ -81,37 +109,30 @@ export function createAuthMiddleware(env: ApiEnv, handle: Db) { return; } - const header = c.req.header("authorization"); - if (!header?.startsWith("Bearer ")) { - return c.json({ error: "Missing or invalid Authorization header." }, 401); + const stage = cookieStage(env); + const idToken = sessionCookieValue(cookieEventFromHeader(c.req.header("cookie")), "id", stage); + if (!idToken) { + return errorJson(c, 401, "UNAUTHENTICATED", "Missing id token."); } - if (!jwks) { - return c.json({ error: "JWT verification is not configured." }, 401); - } - - const token = header.slice("Bearer ".length); let payload: JWTPayload; try { - ({ payload } = await jwtVerify(token, jwks, { - issuer: env.cognitoIssuer, - })); + payload = await verifyToken(idToken); } catch { - return c.json({ error: "Invalid or expired token." }, 401); + return errorJson(c, 401, "UNAUTHENTICATED", "Invalid or expired token."); } if (!audienceMatches(payload, env.cognitoAudience)) { - return c.json({ error: "Token audience does not match this API." }, 401); + return errorJson(c, 401, "UNAUTHENTICATED", "Token audience does not match this API."); } const identity = identityFromPayload(payload); if (!identity) { - return c.json( - { - error: - "Token is missing required identity claims. Use a Cognito ID token or an access token that includes email.", - }, + return errorJson( + c, 401, + "UNAUTHENTICATED", + "Token is missing required identity claims. Use a Cognito ID token or an access token that includes email.", ); } @@ -121,11 +142,11 @@ export function createAuthMiddleware(env: ApiEnv, handle: Db) { cognitoSub: identity.sub, email: identity.email, name: identity.name, - role: roleFromClaims(payload as Record, "viewer"), + role: roleFromClaims(payload as Record), }); } catch (error) { if (error instanceof IdentityConflictError) { - return c.json({ error: error.message }, 409); + return errorJson(c, 409, "CONFLICT", error.message); } throw error; } diff --git a/packages/api/src/auth/oauth.test.ts b/packages/api/src/auth/oauth.test.ts new file mode 100644 index 0000000..3b18fa9 --- /dev/null +++ b/packages/api/src/auth/oauth.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { cookieStage, safeReturnTo, signinErrorLocation } from "./oauth.js"; + +describe("oauth helpers", () => { + it("honors a relative returnTo and rejects open redirects", () => { + expect(safeReturnTo("/invoices")).toBe("/invoices"); + expect(safeReturnTo("//evil.com")).toBe("/"); + expect(safeReturnTo("/login")).toBe("/"); + expect(safeReturnTo("https://evil.com")).toBe("/"); + expect(safeReturnTo("/invoices?tab=2#top")).toBe("/invoices?tab=2#top"); + expect(signinErrorLocation("/invoices")).toBe("/login?error=1&returnTo=%2Finvoices"); + expect(signinErrorLocation("/")).toBe("/login?error=1"); + }); + + it("uses local cookie names for development and test", () => { + expect(cookieStage({ nodeEnv: "test", stage: "dev" })).toBe("local"); + expect(cookieStage({ nodeEnv: "development", stage: "dev" })).toBe("local"); + expect(cookieStage({ nodeEnv: "production", stage: "dev" })).toBe("dev"); + }); +}); diff --git a/packages/api/src/auth/oauth.ts b/packages/api/src/auth/oauth.ts new file mode 100644 index 0000000..09105eb --- /dev/null +++ b/packages/api/src/auth/oauth.ts @@ -0,0 +1,229 @@ +import type { Context } from "hono"; +import type { ApiEnv } from "../env.js"; +import { errorJson } from "../http.js"; +import { + cookieEventFromHeader, + cookieNames, + clearCookie, + clearedSessionCookies, + readOauthCookie, + serializeOauthCookie, + sessionCookieValue, + tokenCookies, + type CookieEvent, +} from "./cookies.js"; +import { + authorizeUrl, + callbackRedirectUri, + createCognitoTokenClient, + type CognitoTokenClient, +} from "./cognito.js"; +import { createPkce, safeEqual } from "./pkce.js"; + +const LOCAL_ORIGINS = [ + "http://127.0.0.1:3000", + "http://localhost:3000", + "http://127.0.0.1:8787", + "http://localhost:8787", +] as const; + +export function cookieStage(env: Pick): string { + if (env.nodeEnv === "development" || env.nodeEnv === "test" || env.stage === "local") { + return "local"; + } + return env.stage; +} + +export function isPublicRoute(method: string, path: string): boolean { + if (method === "GET" && path === "/api/health") return true; + if (method === "GET" && path === "/api/ready") return true; + if (method === "GET" && path === "/api/auth/login") return true; + if (method === "GET" && path === "/api/auth/callback") return true; + if (method === "POST" && path === "/api/auth/refresh") return true; + if (method === "POST" && path === "/api/auth/logout") return true; + return false; +} + +export function isMutating(method: string): boolean { + return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE"; +} + +export function allowedOrigins(env: Pick): Set { + const origins = new Set(); + if (cookieStage(env) === "local") { + for (const origin of LOCAL_ORIGINS) origins.add(origin); + } + const app = env.appOrigin.replace(/\/$/, ""); + if (app) origins.add(app); + return origins; +} + +export function csrfAllowed( + c: Context, + env: Pick, +): boolean { + const origin = c.req.header("origin")?.trim(); + if (!origin) return false; + return allowedOrigins(env).has(origin); +} + +const RETURN_TO_BASE = "https://return-to.invalid"; + +function hasControlCharacter(value: string): boolean { + for (const ch of value) { + const code = ch.codePointAt(0) ?? 0; + if (code < 0x20 || code === 0x7f) return true; + } + return false; +} + +function isPlainAfterDecoding(value: string): boolean { + let current = value; + for (let i = 0; i < 2; i += 1) { + let decoded: string; + try { + decoded = decodeURIComponent(current); + } catch { + return false; + } + if (decoded.includes("\\") || hasControlCharacter(decoded)) return false; + if (decoded === current) break; + current = decoded; + } + return true; +} + +export function safeReturnTo(raw: string | null | undefined): string { + if (!raw) return "/"; + const value = raw.trim(); + if (!value.startsWith("/")) return "/"; + if (value.includes("\\") || hasControlCharacter(value)) return "/"; + if (!isPlainAfterDecoding(value)) return "/"; + let url: URL; + try { + url = new URL(value, RETURN_TO_BASE); + } catch { + return "/"; + } + if (url.origin !== RETURN_TO_BASE) return "/"; + if (url.pathname === "/login") return "/"; + const resolved = `${url.pathname}${url.search}${url.hash}`; + if (!resolved.startsWith("/") || resolved.startsWith("//")) return "/"; + return resolved; +} + +export function signinErrorLocation(returnTo?: string | null): string { + const safe = safeReturnTo(returnTo); + if (safe === "/") return "/login?error=1"; + return `/login?error=1&returnTo=${encodeURIComponent(safe)}`; +} + +export function applyCookies(c: Context, cookies: string[]): void { + for (const cookie of cookies) { + c.header("set-cookie", cookie, { append: true }); + } +} + +function cookieEvent(c: Context): CookieEvent { + return cookieEventFromHeader(c.req.header("cookie")); +} + +export async function handleLogin(c: Context, env: ApiEnv) { + if (!env.cognitoAudience || !env.cognitoDomain) { + return errorJson(c, 500, "INTERNAL_ERROR", "Cognito is not configured."); + } + const returnTo = safeReturnTo(c.req.query("returnTo")); + const pkce = createPkce(); + const location = authorizeUrl({ + domain: env.cognitoDomain, + clientId: env.cognitoAudience, + redirectUri: callbackRedirectUri(env.appOrigin), + state: pkce.state, + challenge: pkce.challenge, + }); + const stage = cookieStage(env); + applyCookies(c, [ + serializeOauthCookie({ state: pkce.state, verifier: pkce.verifier, returnTo }, stage), + ]); + return c.redirect(location, 302); +} + +export async function handleCallback( + c: Context, + env: ApiEnv, + tokens: CognitoTokenClient = createCognitoTokenClient(env), +) { + const stage = cookieStage(env); + const oauth = readOauthCookie(cookieEvent(c), stage); + const fail = () => { + applyCookies(c, [clearCookie(cookieNames(stage).oauth, stage)]); + return c.redirect(signinErrorLocation(oauth?.returnTo), 302); + }; + + if (c.req.query("error")) return fail(); + const code = c.req.query("code")?.trim(); + const state = c.req.query("state")?.trim(); + if (!code || !state || !oauth || !safeEqual(state, oauth.state)) return fail(); + + try { + const exchanged = await tokens.exchangeCode({ + code, + verifier: oauth.verifier, + redirectUri: callbackRedirectUri(env.appOrigin), + }); + if (!exchanged.refreshToken) return fail(); + applyCookies(c, tokenCookies(exchanged, stage)); + return c.redirect(safeReturnTo(oauth.returnTo), 302); + } catch { + return fail(); + } +} + +export async function handleRefresh( + c: Context, + env: ApiEnv, + tokens: CognitoTokenClient = createCognitoTokenClient(env), +) { + const stage = cookieStage(env); + const refreshToken = sessionCookieValue(cookieEvent(c), "refresh", stage); + if (!refreshToken) { + applyCookies(c, clearedSessionCookies(stage)); + return errorJson(c, 401, "UNAUTHENTICATED", "Missing refresh token."); + } + try { + const rotated = await tokens.refresh(refreshToken); + applyCookies( + c, + tokenCookies( + { + accessToken: rotated.accessToken, + idToken: rotated.idToken, + refreshToken: rotated.refreshToken ?? refreshToken, + }, + stage, + ), + ); + return c.body(null, 204); + } catch { + applyCookies(c, clearedSessionCookies(stage)); + return errorJson(c, 401, "UNAUTHENTICATED", "Refresh failed."); + } +} + +export async function handleLogout( + c: Context, + env: ApiEnv, + tokens: CognitoTokenClient = createCognitoTokenClient(env), +) { + const stage = cookieStage(env); + const refreshToken = sessionCookieValue(cookieEvent(c), "refresh", stage); + if (refreshToken && env.cognitoDomain && env.cognitoAudience) { + try { + await tokens.revoke(refreshToken); + } catch { + // Still clear cookies so the browser session ends. + } + } + applyCookies(c, clearedSessionCookies(stage)); + return c.body(null, 204); +} diff --git a/packages/api/src/auth/origin-verify.ts b/packages/api/src/auth/origin-verify.ts new file mode 100644 index 0000000..dfa5516 --- /dev/null +++ b/packages/api/src/auth/origin-verify.ts @@ -0,0 +1,18 @@ +import { timingSafeEqual } from "node:crypto"; +import type { Context } from "hono"; + +export const ORIGIN_VERIFY_HEADER = "x-origin-verify"; + +export function originVerifyHeader(c: Context): string { + return c.req.header(ORIGIN_VERIFY_HEADER)?.trim() ?? ""; +} + +/** When a secret is configured, only CloudFront's origin header is accepted. */ +export function cloudFrontOriginAllowed(c: Context, secret: string | undefined): boolean { + if (!secret) return true; + const provided = originVerifyHeader(c); + const a = Buffer.from(provided); + const b = Buffer.from(secret); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} diff --git a/packages/api/src/auth/pkce.ts b/packages/api/src/auth/pkce.ts new file mode 100644 index 0000000..a63056d --- /dev/null +++ b/packages/api/src/auth/pkce.ts @@ -0,0 +1,23 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +const STATE_BYTES = 32; +const VERIFIER_BYTES = 32; + +export function randomToken(bytes = STATE_BYTES): string { + return randomBytes(bytes).toString("base64url"); +} + +export function createPkce(): { verifier: string; challenge: string; state: string } { + const verifier = randomToken(VERIFIER_BYTES); + return { verifier, challenge: pkceChallenge(verifier), state: randomToken() }; +} + +export function pkceChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +export function safeEqual(left: string, right: string): boolean { + const hashedLeft = createHash("sha256").update(left).digest(); + const hashedRight = createHash("sha256").update(right).digest(); + return timingSafeEqual(hashedLeft, hashedRight) && left.length === right.length; +} diff --git a/packages/api/src/auth/upsert-user.test.ts b/packages/api/src/auth/upsert-user.test.ts index 8eceaa8..882a313 100644 --- a/packages/api/src/auth/upsert-user.test.ts +++ b/packages/api/src/auth/upsert-user.test.ts @@ -5,7 +5,7 @@ import { IdentityConflictError, upsertUserFromIdentity } from "./upsert-user.js" function createDb(options: { bySub?: Record | null; byEmail?: Record | null; -}): Db { +}): { handle: Db; setSpy: ReturnType } { const findFirst = vi.fn(async (_args: { where: unknown }) => { // drizzle eq objects aren't introspectable here; alternate by call order. if (findFirst.mock.calls.length === 1) { @@ -22,30 +22,37 @@ function createDb(options: { role: "admin" as const, }; + const setSpy = vi.fn((patch: Record) => ({ + where: vi.fn(() => ({ + returning: vi.fn(async () => [ + { ...returningRow, ...patch, role: patch.role ?? returningRow.role }, + ]), + })), + })); + return { - driver: "postgres", - pool: { end: vi.fn(async () => undefined) } as never, - db: { - query: { users: { findFirst } }, - update: vi.fn(() => ({ - set: vi.fn(() => ({ - where: vi.fn(() => ({ + handle: { + driver: "postgres", + pool: { end: vi.fn(async () => undefined) } as never, + db: { + query: { users: { findFirst } }, + update: vi.fn(() => ({ + set: setSpy, + })), + insert: vi.fn(() => ({ + values: vi.fn(() => ({ returning: vi.fn(async () => [returningRow]), })), })), - })), - insert: vi.fn(() => ({ - values: vi.fn(() => ({ - returning: vi.fn(async () => [returningRow]), - })), - })), - } as never, + } as never, + }, + setSpy, }; } describe("upsertUserFromIdentity", () => { it("updates an existing row matched by cognito sub", async () => { - const handle = createDb({ + const { handle } = createDb({ bySub: { id: "11111111-1111-4111-8111-111111111111", cognitoSub: "seed-sub-admin", @@ -66,8 +73,50 @@ describe("upsertUserFromIdentity", () => { expect(handle.db.update).toHaveBeenCalled(); }); + it("preserves the stored role when the token omits a role claim", async () => { + const { handle, setSpy } = createDb({ + bySub: { + id: "11111111-1111-4111-8111-111111111111", + cognitoSub: "seed-sub-admin", + email: "admin@seahavenind.com", + name: "Dev Admin", + role: "admin", + }, + }); + + const user = await upsertUserFromIdentity(handle, { + cognitoSub: "seed-sub-admin", + email: "admin@seahavenind.com", + name: "Dev Admin", + }); + + expect(setSpy).toHaveBeenCalledWith(expect.not.objectContaining({ role: expect.anything() })); + expect(user.role).toBe("admin"); + }); + + it("writes an explicit role claim over the stored role", async () => { + const { handle, setSpy } = createDb({ + bySub: { + id: "11111111-1111-4111-8111-111111111111", + cognitoSub: "seed-sub-admin", + email: "admin@seahavenind.com", + name: "Dev Admin", + role: "viewer", + }, + }); + + await upsertUserFromIdentity(handle, { + cognitoSub: "seed-sub-admin", + email: "admin@seahavenind.com", + name: "Dev Admin", + role: "approver", + }); + + expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({ role: "approver" })); + }); + it("refuses to rebind an email owned by a different cognito sub", async () => { - const handle = createDb({ + const { handle } = createDb({ bySub: null, byEmail: { id: "11111111-1111-4111-8111-111111111111", diff --git a/packages/api/src/auth/upsert-user.ts b/packages/api/src/auth/upsert-user.ts index d0373a5..fdf3201 100644 --- a/packages/api/src/auth/upsert-user.ts +++ b/packages/api/src/auth/upsert-user.ts @@ -7,7 +7,8 @@ export type AuthIdentity = { cognitoSub: string; email: string; name: string; - role: UserRole; + /** Present only when the token carries an explicit role claim. */ + role?: UserRole; }; export type AuthUser = { @@ -46,6 +47,7 @@ function toAuthUser(row: { /** * Upsert by Cognito subject only. Never rebind an existing email to a new * subject — that would allow account takeover if email claims collide. + * Missing role claims leave the stored role unchanged. */ export async function upsertUserFromIdentity( handle: Db, @@ -56,14 +58,22 @@ export async function upsertUserFromIdentity( }); if (bySub) { + const patch: { + email: string; + name: string; + role?: UserRole; + updatedAt: Date; + } = { + email: identity.email, + name: identity.name, + updatedAt: new Date(), + }; + if (identity.role !== undefined) { + patch.role = identity.role; + } const [updated] = await handle.db .update(users) - .set({ - email: identity.email, - name: identity.name, - role: identity.role, - updatedAt: new Date(), - }) + .set(patch) .where(eq(users.id, bySub.id)) .returning(); return toAuthUser(updated); @@ -83,7 +93,7 @@ export async function upsertUserFromIdentity( cognitoSub: identity.cognitoSub, email: identity.email, name: identity.name, - role: identity.role, + role: identity.role ?? "viewer", }) .returning(); diff --git a/packages/api/src/build-info.ts b/packages/api/src/build-info.ts new file mode 100644 index 0000000..408b803 --- /dev/null +++ b/packages/api/src/build-info.ts @@ -0,0 +1,9 @@ +// Build-time constant. The API image Dockerfile overwrites the compiled +// `build-info.js` with an inlined GIT_SHA after `tsc`. Keep the exact +// expression `process.env.GIT_SHA` here so local `tsx` still reads the env. +// +// Consumers must prefer BUILD_GIT_SHA over a runtime GIT_SHA env var. A +// deployed task can carry a stale env var from an earlier infra apply while +// its image has since been updated by deploy-api.yaml. + +export const BUILD_GIT_SHA: string = process.env.GIT_SHA?.trim() || ""; diff --git a/packages/api/src/db/client.ts b/packages/api/src/db/client.ts index cddde03..12195e9 100644 --- a/packages/api/src/db/client.ts +++ b/packages/api/src/db/client.ts @@ -10,6 +10,17 @@ export type PostgresDb = ReturnType; export type DataApiDb = ReturnType; export type Db = PostgresDb | DataApiDb; +type PostgresTx = Parameters[0]>[0]; +type DataApiTx = Parameters[0]>[0]; + +/** + * Root connection or a transaction-scoped handle. Query helpers accept this so + * callers can pass `{ db: tx }` without casting a transaction to Db. + */ +export type DbHandle = { + db: Db["db"] | PostgresTx | DataApiTx; +}; + function createPostgresDb(env: ApiEnv) { const pool = new pg.Pool({ connectionString: env.databaseUrl }); return { diff --git a/packages/api/src/db/migrate.ts b/packages/api/src/db/migrate.ts index 1506164..26fe890 100644 --- a/packages/api/src/db/migrate.ts +++ b/packages/api/src/db/migrate.ts @@ -2,15 +2,12 @@ import { migrate } from "drizzle-orm/node-postgres/migrator"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { closeDb, createDb } from "./client.js"; -import { loadEnv } from "../env.js"; +import { loadEnv, withLocalDevAuthBypass } from "../env.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function main(): Promise { - const env = loadEnv({ - ...process.env, - DEV_AUTH_BYPASS: process.env.DEV_AUTH_BYPASS ?? "true", - }); + const env = loadEnv(withLocalDevAuthBypass()); if (env.databaseDriver !== "postgres") { throw new Error("db:migrate currently supports DATABASE_DRIVER=postgres only."); diff --git a/packages/api/src/documents.ts b/packages/api/src/documents.ts new file mode 100644 index 0000000..0ad0b99 --- /dev/null +++ b/packages/api/src/documents.ts @@ -0,0 +1,77 @@ +import { + GetObjectCommand, + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import type { ApiEnv } from "./env.js"; + +export type PresignPutResult = { + url: string; + headers: Record; +}; + +export type DocumentsStore = { + presignPut(input: { objectKey: string; contentType: string }): Promise; + objectExists(objectKey: string): Promise; + presignGet(objectKey: string): Promise; +}; + +const SIGN_EXPIRES_SECONDS = 900; + +export function createDocumentsStore(env: ApiEnv): DocumentsStore { + const client = new S3Client({ + region: env.awsRegion, + endpoint: env.documentsEndpoint || undefined, + forcePathStyle: Boolean(env.documentsEndpoint), + credentials: + env.documentsAccessKey && env.documentsSecretKey + ? { accessKeyId: env.documentsAccessKey, secretAccessKey: env.documentsSecretKey } + : undefined, + }); + const bucket = env.documentsBucket; + + return { + async presignPut({ objectKey, contentType }) { + const url = await getSignedUrl( + client, + new PutObjectCommand({ Bucket: bucket, Key: objectKey, ContentType: contentType }), + { expiresIn: SIGN_EXPIRES_SECONDS }, + ); + return { url, headers: { "Content-Type": contentType } }; + }, + async objectExists(objectKey) { + try { + await client.send(new HeadObjectCommand({ Bucket: bucket, Key: objectKey })); + return true; + } catch { + return false; + } + }, + async presignGet(objectKey) { + return getSignedUrl(client, new GetObjectCommand({ Bucket: bucket, Key: objectKey }), { + expiresIn: SIGN_EXPIRES_SECONDS, + }); + }, + }; +} + +export function createMemoryDocumentsStore(): DocumentsStore & { uploaded: Set } { + const uploaded = new Set(); + return { + uploaded, + async presignPut({ objectKey, contentType }) { + return { + url: `http://127.0.0.1:9000/seahaven-ap-documents/${objectKey}?presign=put`, + headers: { "Content-Type": contentType }, + }; + }, + async objectExists(objectKey) { + return uploaded.has(objectKey); + }, + async presignGet(objectKey) { + return `http://127.0.0.1:9000/seahaven-ap-documents/${objectKey}?presign=get`; + }, + }; +} diff --git a/packages/api/src/env.test.ts b/packages/api/src/env.test.ts index 26d642c..8b0350d 100644 --- a/packages/api/src/env.test.ts +++ b/packages/api/src/env.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { loadEnv } from "./env.js"; +import { loadEnv, withLocalDevAuthBypass } from "./env.js"; describe("loadEnv", () => { it("allows DEV_AUTH_BYPASS in development", () => { @@ -11,14 +11,17 @@ describe("loadEnv", () => { expect(env.devAuthBypass).toBe(true); expect(env.devAuthRole).toBe("viewer"); expect(env.port).toBe(8787); + expect(env.stage).toBe("local"); + expect(env.sha).toBe("unknown"); }); - it("allows DEV_AUTH_BYPASS in test", () => { + it("uses STAGE when provided", () => { const env = loadEnv({ NODE_ENV: "test", DEV_AUTH_BYPASS: "true", + STAGE: "dev", }); - expect(env.devAuthBypass).toBe(true); + expect(env.stage).toBe("dev"); }); it("rejects DEV_AUTH_BYPASS in production", () => { @@ -46,6 +49,18 @@ describe("loadEnv", () => { ).toThrow(/DEV_AUTH_BYPASS/); }); + it("defaults DEV_AUTH_BYPASS only for local node envs", () => { + expect(withLocalDevAuthBypass({ NODE_ENV: "development" }).DEV_AUTH_BYPASS).toBe("true"); + expect(withLocalDevAuthBypass({ NODE_ENV: "test" }).DEV_AUTH_BYPASS).toBe("true"); + expect(withLocalDevAuthBypass({ NODE_ENV: "production" }).DEV_AUTH_BYPASS).toBeUndefined(); + expect( + withLocalDevAuthBypass({ NODE_ENV: "production", DEV_AUTH_BYPASS: "false" }).DEV_AUTH_BYPASS, + ).toBe("false"); + expect(() => loadEnv(withLocalDevAuthBypass({ NODE_ENV: "production" }))).toThrow( + /COGNITO_ISSUER/, + ); + }); + it("requires Cognito config when bypass is off", () => { expect(() => loadEnv({ diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index 05d0ee2..12969b3 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -1,3 +1,5 @@ +import { BUILD_GIT_SHA } from "./build-info.js"; + export const USER_ROLES = ["admin", "ap_processor", "approver", "viewer"] as const; export type UserRole = (typeof USER_ROLES)[number]; @@ -6,8 +8,19 @@ export function isUserRole(value: string): value is UserRole { return (USER_ROLES as readonly string[]).includes(value); } +const LOCAL_NODE_ENVS = new Set(["development", "test"]); + +export function withLocalDevAuthBypass(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + if (env.DEV_AUTH_BYPASS !== undefined) return env; + const nodeEnv = env.NODE_ENV ?? "development"; + if (!LOCAL_NODE_ENVS.has(nodeEnv)) return env; + return { ...env, DEV_AUTH_BYPASS: "true" }; +} + export type ApiEnv = { nodeEnv: string; + stage: string; + sha: string; port: number; databaseDriver: "postgres" | "data-api"; databaseUrl: string; @@ -17,6 +30,13 @@ export type ApiEnv = { rdsDatabase: string; cognitoIssuer: string; cognitoAudience: string; + cognitoDomain: string; + appOrigin: string; + originVerifySecret: string; + documentsEndpoint: string; + documentsAccessKey: string; + documentsSecretKey: string; + documentsBucket: string; devAuthBypass: boolean; devAuthSub: string; devAuthEmail: string; @@ -39,8 +59,7 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv { } const devAuthBypass = env.DEV_AUTH_BYPASS === "true"; - const localNodeEnvs = new Set(["development", "test"]); - if (devAuthBypass && !localNodeEnvs.has(nodeEnv)) { + if (devAuthBypass && !LOCAL_NODE_ENVS.has(nodeEnv)) { throw new Error("DEV_AUTH_BYPASS is only allowed when NODE_ENV is development or test."); } @@ -49,8 +68,16 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv { throw new Error(`Invalid DEV_AUTH_ROLE: ${rawRole}`); } + const local = LOCAL_NODE_ENVS.has(nodeEnv); + const stage = env.STAGE?.trim() || (local ? "local" : "dev"); + // SHA is inlined at image build. Runtime GIT_SHA is a local-dev convenience + // only and must not be the deployed source of truth. + const sha = BUILD_GIT_SHA || env.GIT_SHA?.trim() || "unknown"; + const base: ApiEnv = { nodeEnv, + stage, + sha, port: Number(env.API_PORT ?? "8787"), databaseDriver, databaseUrl: env.DATABASE_URL ?? "postgresql://seahaven:seahaven@127.0.0.1:5432/seahaven_ap", @@ -60,6 +87,14 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv { rdsDatabase: env.RDS_DATABASE ?? "seahaven_ap", cognitoIssuer: env.COGNITO_ISSUER ?? "", cognitoAudience: env.COGNITO_AUDIENCE ?? "", + cognitoDomain: env.COGNITO_DOMAIN?.trim() ?? "", + appOrigin: env.APP_ORIGIN?.trim() || (local ? "http://127.0.0.1:3000" : ""), + originVerifySecret: env.ORIGIN_VERIFY_SECRET?.trim() ?? "", + documentsEndpoint: env.DOCUMENTS_ENDPOINT?.trim() || env.MINIO_ENDPOINT?.trim() || "", + documentsAccessKey: env.DOCUMENTS_ACCESS_KEY?.trim() || env.MINIO_ACCESS_KEY?.trim() || "", + documentsSecretKey: env.DOCUMENTS_SECRET_KEY?.trim() || env.MINIO_SECRET_KEY?.trim() || "", + documentsBucket: + env.DOCUMENTS_BUCKET?.trim() || env.MINIO_BUCKET?.trim() || "seahaven-ap-documents", devAuthBypass, devAuthSub: env.DEV_AUTH_SUB ?? "seed-sub-admin", devAuthEmail: env.DEV_AUTH_EMAIL ?? "admin@seahavenind.com", diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts new file mode 100644 index 0000000..bee88b8 --- /dev/null +++ b/packages/api/src/http.ts @@ -0,0 +1,57 @@ +import { randomUUID } from "node:crypto"; +import type { Context } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +export type ErrorCode = + | "UNAUTHENTICATED" + | "FORBIDDEN" + | "NOT_FOUND" + | "VALIDATION_ERROR" + | "CONFLICT" + | "INTERNAL_ERROR" + | "DATABASE_UNAVAILABLE"; + +export class ApiError extends Error { + constructor( + readonly status: ContentfulStatusCode, + readonly code: ErrorCode, + message: string, + ) { + super(message); + this.name = "ApiError"; + } +} + +export const CORRELATION_HEADER = "x-correlation-id"; + +export type ErrorEnvelope = { + error: { + code: ErrorCode; + message: string; + correlationId: string; + }; +}; + +export function correlationIdFrom(c: Context): string { + const raw = c.req.header(CORRELATION_HEADER)?.trim(); + if (raw && raw.length <= 128) { + return raw; + } + return randomUUID(); +} + +export function errorBody(code: ErrorCode, message: string, correlationId: string): ErrorEnvelope { + return { error: { code, message, correlationId } }; +} + +export function errorJson( + c: Context, + status: ContentfulStatusCode, + code: ErrorCode, + message: string, + correlationId?: string, +) { + const id = correlationId ?? correlationIdFrom(c); + c.header(CORRELATION_HEADER, id); + return c.json(errorBody(code, message, id), status); +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 9f54b4f..74f214d 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -8,8 +8,8 @@ async function main(): Promise { const handle = createDb(env); const app = createApp(env, handle); - const server = serve({ fetch: app.fetch, port: env.port }, (info) => { - console.log(`@seahaven-ap/api listening on http://127.0.0.1:${info.port}`); + const server = serve({ fetch: app.fetch, hostname: "0.0.0.0", port: env.port }, (info) => { + console.log(`@seahaven-ap/api listening on http://0.0.0.0:${info.port}`); }); const shutdown = async () => { diff --git a/packages/api/src/routes/approvals.test.ts b/packages/api/src/routes/approvals.test.ts new file mode 100644 index 0000000..eafb6d8 --- /dev/null +++ b/packages/api/src/routes/approvals.test.ts @@ -0,0 +1,160 @@ +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, emptyStore, 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("returns 403 when the caller is not the assignee for the step", async () => { + const store = emptyStore(); + store.approvalSteps[0] = { + ...SEED.step, + assigneeUserId: "22222222-2222-4222-8222-222222222222", + }; + const api = createApp(envFor("admin"), createFakeDb(store), { + documents: createMemoryDocumentsStore(), + }); + 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"); + expect(store.approvalSteps[0]?.status).toBe("pending"); + }); + + it("voids an invoice, closes pending steps, and rejects later decisions", async () => { + const store = emptyStore(); + const api = createApp(envFor("admin"), createFakeDb(store), { + documents: createMemoryDocumentsStore(), + }); + const voided = await api.request(`/api/invoices/${SEED.invoice.id}`, { + method: "PATCH", + headers: jsonHeaders, + body: JSON.stringify({ status: "void" }), + }); + expect(voided.status).toBe(200); + await expect(voided.json()).resolves.toMatchObject({ status: "void" }); + expect(store.approvalSteps[0]?.status).toBe("skipped"); + + 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.some((item) => item.id === SEED.step.id)).toBe(false); + + // Re-open a pending step to prove voided invoices stay sealed even if a step remains. + store.approvalSteps[0] = { ...store.approvalSteps[0]!, status: "pending" }; + const decision = await api.request(`/api/approval-steps/${SEED.step.id}/decisions`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ action: "approve" }), + }); + expect(decision.status).toBe(409); + expectEnvelope(await decision.json(), "CONFLICT"); + expect(store.invoices[0]?.status).toBe("void"); + }); + + 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..cd48c7d --- /dev/null +++ b/packages/api/src/routes/approvals.ts @@ -0,0 +1,293 @@ +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 user = caller(c); + if (!inboxVisible(step, user)) { + return errorJson(c, 403, "FORBIDDEN", "You are not an assignee for this approval step."); + } + const invoice = await firstById(handle, invoices, step.invoiceId); + if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + if (invoice.status === "void") { + return errorJson(c, 409, "CONFLICT", "Cannot act on a voided invoice."); + } + 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 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/auth.ts b/packages/api/src/routes/auth.ts new file mode 100644 index 0000000..67196be --- /dev/null +++ b/packages/api/src/routes/auth.ts @@ -0,0 +1,16 @@ +import { Hono } from "hono"; +import type { ApiEnv } from "../env.js"; +import type { CognitoTokenClient } from "../auth/cognito.js"; +import { handleCallback, handleLogin, handleLogout, handleRefresh } from "../auth/oauth.js"; +import type { AppBindings } from "../auth/middleware.js"; + +export function createAuthRoutes(env: ApiEnv, deps: { tokens?: CognitoTokenClient } = {}) { + const routes = new Hono(); + + routes.get("/auth/login", (c) => handleLogin(c, env)); + routes.get("/auth/callback", (c) => handleCallback(c, env, deps.tokens)); + routes.post("/auth/refresh", (c) => handleRefresh(c, env, deps.tokens)); + routes.post("/auth/logout", (c) => handleLogout(c, env, deps.tokens)); + + return routes; +} diff --git a/packages/api/src/routes/departments.ts b/packages/api/src/routes/departments.ts new file mode 100644 index 0000000..d46a625 --- /dev/null +++ b/packages/api/src/routes/departments.ts @@ -0,0 +1,80 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import type { Db } from "../db/client.js"; +import { departments } from "../db/schema/index.js"; +import type { AppBindings } from "../auth/middleware.js"; +import { errorJson } from "../http.js"; +import { asString, iso, isUuid, parseJsonBody, requireCan } from "./helpers.js"; + +function toDepartment(row: typeof departments.$inferSelect) { + return { + id: row.id, + code: row.code, + name: row.name, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + }; +} + +export function createDepartmentRoutes(handle: Db) { + const routes = new Hono(); + + routes.get("/departments", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const rows = await handle.db.select().from(departments); + return c.json({ items: rows.map(toDepartment) }); + }); + + routes.get("/departments/: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 department id."); + const row = await handle.db.query.departments.findFirst({ where: eq(departments.id, id) }); + if (!row) return errorJson(c, 404, "NOT_FOUND", "Department not found."); + return c.json(toDepartment(row)); + }); + + routes.post("/departments", async (c) => { + const denied = requireCan(c, "admin:settings"); + if (denied) return denied; + const body = await parseJsonBody(c); + const code = asString(body.code).trim(); + const name = asString(body.name).trim(); + if (!code || !name) return errorJson(c, 400, "VALIDATION_ERROR", "Code and name are required."); + const [row] = await handle.db.insert(departments).values({ code, name }).returning(); + return c.json(toDepartment(row), 201); + }); + + routes.patch("/departments/: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 department id."); + const existing = await handle.db.query.departments.findFirst({ + where: eq(departments.id, id), + }); + if (!existing) return errorJson(c, 404, "NOT_FOUND", "Department not found."); + const body = await parseJsonBody(c); + const patch: Partial = { updatedAt: new Date() }; + if (body.code !== undefined) { + const code = asString(body.code).trim(); + if (!code) return errorJson(c, 400, "VALIDATION_ERROR", "Code is required."); + patch.code = code; + } + if (body.name !== undefined) { + const name = asString(body.name).trim(); + if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required."); + patch.name = name; + } + const [row] = await handle.db + .update(departments) + .set(patch) + .where(eq(departments.id, id)) + .returning(); + return c.json(toDepartment(row)); + }); + + return routes; +} diff --git a/packages/api/src/routes/gl-accounts.ts b/packages/api/src/routes/gl-accounts.ts new file mode 100644 index 0000000..b889d65 --- /dev/null +++ b/packages/api/src/routes/gl-accounts.ts @@ -0,0 +1,78 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import type { Db } from "../db/client.js"; +import { glAccounts } from "../db/schema/index.js"; +import type { AppBindings } from "../auth/middleware.js"; +import { errorJson } from "../http.js"; +import { asString, iso, isUuid, parseJsonBody, requireCan } from "./helpers.js"; + +function toGlAccount(row: typeof glAccounts.$inferSelect) { + return { + id: row.id, + code: row.code, + name: row.name, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + }; +} + +export function createGlAccountRoutes(handle: Db) { + const routes = new Hono(); + + routes.get("/gl-accounts", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const rows = await handle.db.select().from(glAccounts); + return c.json({ items: rows.map(toGlAccount) }); + }); + + routes.get("/gl-accounts/: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 GL account id."); + const row = await handle.db.query.glAccounts.findFirst({ where: eq(glAccounts.id, id) }); + if (!row) return errorJson(c, 404, "NOT_FOUND", "GL account not found."); + return c.json(toGlAccount(row)); + }); + + routes.post("/gl-accounts", async (c) => { + const denied = requireCan(c, "admin:settings"); + if (denied) return denied; + const body = await parseJsonBody(c); + const code = asString(body.code).trim(); + const name = asString(body.name).trim(); + if (!code || !name) return errorJson(c, 400, "VALIDATION_ERROR", "Code and name are required."); + const [row] = await handle.db.insert(glAccounts).values({ code, name }).returning(); + return c.json(toGlAccount(row), 201); + }); + + routes.patch("/gl-accounts/: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 GL account id."); + const existing = await handle.db.query.glAccounts.findFirst({ where: eq(glAccounts.id, id) }); + if (!existing) return errorJson(c, 404, "NOT_FOUND", "GL account not found."); + const body = await parseJsonBody(c); + const patch: Partial = { updatedAt: new Date() }; + if (body.code !== undefined) { + const code = asString(body.code).trim(); + if (!code) return errorJson(c, 400, "VALIDATION_ERROR", "Code is required."); + patch.code = code; + } + if (body.name !== undefined) { + const name = asString(body.name).trim(); + if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required."); + patch.name = name; + } + const [row] = await handle.db + .update(glAccounts) + .set(patch) + .where(eq(glAccounts.id, id)) + .returning(); + return c.json(toGlAccount(row)); + }); + + return routes; +} diff --git a/packages/api/src/routes/health.ts b/packages/api/src/routes/health.ts index 0aec002..3b75e67 100644 --- a/packages/api/src/routes/health.ts +++ b/packages/api/src/routes/health.ts @@ -1,16 +1,26 @@ import { Hono } from "hono"; +import type { ApiEnv } from "../env.js"; import type { Db } from "../db/client.js"; import { pingDb } from "../db/client.js"; +import { CORRELATION_HEADER, correlationIdFrom, errorJson } from "../http.js"; -export function createHealthRoutes(handle: Db) { +export function createHealthRoutes(env: ApiEnv, handle: Db) { const routes = new Hono(); - routes.get("/health", async (c) => { + routes.get("/health", (c) => { + const correlationId = correlationIdFrom(c); + c.header(CORRELATION_HEADER, correlationId); + return c.json({ stage: env.stage, sha: env.sha }); + }); + + routes.get("/ready", async (c) => { try { await pingDb(handle); + const correlationId = correlationIdFrom(c); + c.header(CORRELATION_HEADER, correlationId); return c.json({ status: "ok", database: "up" }); } catch { - return c.json({ error: "Database is unavailable." }, 503); + return errorJson(c, 503, "DATABASE_UNAVAILABLE", "Database is unavailable."); } }); diff --git a/packages/api/src/routes/helpers.ts b/packages/api/src/routes/helpers.ts new file mode 100644 index 0000000..ef75037 --- /dev/null +++ b/packages/api/src/routes/helpers.ts @@ -0,0 +1,94 @@ +import { randomUUID } from "node:crypto"; +import type { Context } from "hono"; +import type { DbHandle } from "../db/client.js"; +import type { UserRole } from "../env.js"; +import { can, type RbacAction } from "../auth/rbac.js"; +import { ApiError, errorJson } from "../http.js"; +import type { AppBindings } from "../auth/middleware.js"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function isUuid(value: string): boolean { + return UUID_RE.test(value); +} + +export function requireCan(c: Context, action: RbacAction) { + const user = c.get("user"); + if (!can(user.role, action)) { + return errorJson(c, 403, "FORBIDDEN", `Role ${user.role} is not allowed to ${action}.`); + } + return null; +} + +export function caller(c: Context) { + return c.get("user"); +} + +export function iso(value: Date | string): string { + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +export function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +export function optionalString(value: unknown): string | null | undefined { + if (value === undefined) return undefined; + if (value === null) return null; + if (typeof value === "string") return value; + return undefined; +} + +export function newId(): string { + return randomUUID(); +} + +export async function parseJsonBody(c: Context): Promise> { + try { + return await c.req.json>(); + } catch { + throw new ApiError(400, "VALIDATION_ERROR", "Request body must be valid JSON."); + } +} + +export function asMoney(value: unknown): string | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value.toFixed(2); + } + if (typeof value === "string" && /^\d+(\.\d{1,2})?$/.test(value.trim())) { + return Number(value).toFixed(2); + } + return null; +} + +export function moneyCents(value: string): number { + return Math.round(Number(value) * 100); +} + +export function isDateOnly(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/.test(value); +} + +export async function rowsOf(handle: DbHandle, table: unknown): Promise { + return (await handle.db.select().from(table as never)) as T[]; +} + +export async function firstById( + handle: DbHandle, + table: unknown, + id: string, +): Promise { + const rows = await rowsOf(handle, table); + return rows.find((row) => row.id === id); +} + +export function isUniqueViolation(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code: unknown }).code === "23505" + ); +} + +export type { UserRole }; diff --git a/packages/api/src/routes/invoices.test.ts b/packages/api/src/routes/invoices.test.ts new file mode 100644 index 0000000..aab0993 --- /dev/null +++ b/packages/api/src/routes/invoices.test.ts @@ -0,0 +1,261 @@ +import { getTableName } from "drizzle-orm"; +import { describe, expect, it, vi } 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, emptyStore, 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" | "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", +}; + +describe("invoice stubs", () => { + it("lists the seeded invoice", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request("/api/invoices"); + expect(response.status).toBe(200); + const body = (await response.json()) as { items: Array<{ invoiceNumber: string }> }; + expect(body.items[0]?.invoiceNumber).toBe("INV-1001"); + }); + + it("creates an invoice when line amounts sum to the header amount", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request("/api/invoices", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + vendorId: SEED.vendor.id, + invoiceNumber: "INV-2002", + amount: "100.00", + dueDate: "2026-10-01", + lines: [ + { description: "Half", amount: "40.00" }, + { description: "Rest", amount: "60.00" }, + ], + }), + }); + expect(response.status).toBe(201); + await expect(response.json()).resolves.toMatchObject({ + invoiceNumber: "INV-2002", + amount: "100.00", + }); + }); + + it("does not persist a partial invoice when a line insert fails", async () => { + const store = emptyStore(); + const handle = createFakeDb(store); + const db = handle.db as { insert: ReturnType }; + const originalInsert = db.insert.getMockImplementation() ?? db.insert; + db.insert.mockImplementation((table: unknown) => { + if (getTableName(table as never) === "invoice_lines") { + return { + values: () => ({ + returning: async () => { + throw new Error("insert failed"); + }, + }), + }; + } + return originalInsert(table); + }); + const app = createApp(envFor("admin"), handle, { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request("/api/invoices", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + vendorId: SEED.vendor.id, + invoiceNumber: "INV-2003", + amount: "100.00", + dueDate: "2026-10-01", + lines: [ + { description: "Half", amount: "40.00" }, + { description: "Rest", amount: "60.00" }, + ], + }), + }); + expect(response.status).toBe(500); + expect(store.invoices.map((row) => row.invoiceNumber)).toEqual(["INV-1001"]); + expect(store.invoiceLines).toHaveLength(1); + expect(store.invoiceLines[0]?.id).toBe(SEED.line.id); + }); + + it("rejects a duplicate active vendor and invoice number", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request("/api/invoices", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + vendorId: SEED.vendor.id, + invoiceNumber: "INV-1001", + amount: "10.00", + dueDate: "2026-10-01", + }), + }); + expect(response.status).toBe(409); + expectEnvelope(await response.json(), "CONFLICT"); + }); + + it("rejects a line replace whose amounts do not sum to the invoice", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request(`/api/invoices/${SEED.invoice.id}/lines`, { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify({ + items: [{ description: "Too small", amount: "1.00" }], + }), + }); + expect(response.status).toBe(400); + expectEnvelope(await response.json(), "VALIDATION_ERROR"); + }); + + it("replaces lines when the amounts sum to the invoice", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request(`/api/invoices/${SEED.invoice.id}/lines`, { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify({ + items: [ + { description: "Labor", amount: "1000.00", glAccountId: SEED.gl.id }, + { description: "Parts", amount: "250.00", departmentId: SEED.department.id }, + ], + }), + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { items: Array<{ amount: string }> }; + expect(body.items).toHaveLength(2); + }); + + it("rejects PATCH that sets approved without an approval decision", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request(`/api/invoices/${SEED.invoice.id}`, { + method: "PATCH", + headers: jsonHeaders, + body: JSON.stringify({ status: "approved" }), + }); + expect(response.status).toBe(400); + expectEnvelope(await response.json(), "VALIDATION_ERROR"); + }); + + it("returns 400 VALIDATION_ERROR for a malformed JSON body", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request(`/api/invoices/${SEED.invoice.id}`, { + method: "PATCH", + headers: jsonHeaders, + body: "{not-json", + }); + expect(response.status).toBe(400); + const body = (await response.json()) as ErrorEnvelope; + expect(body.error.code).toBe("VALIDATION_ERROR"); + expect(body.error.message).toBe("Request body must be valid JSON."); + }); + + it("keeps existing lines when a replace insert fails", async () => { + const store = emptyStore(); + const handle = createFakeDb(store); + const db = handle.db as { insert: ReturnType }; + db.insert.mockImplementation(() => ({ + values: () => ({ + returning: async () => { + throw new Error("insert failed"); + }, + }), + })); + const app = createApp(envFor("admin"), handle, { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request(`/api/invoices/${SEED.invoice.id}/lines`, { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify({ + items: [ + { description: "Labor", amount: "1000.00" }, + { description: "Parts", amount: "250.00" }, + ], + }), + }); + expect(response.status).toBe(500); + expect(store.invoiceLines).toHaveLength(1); + expect(store.invoiceLines[0]?.id).toBe(SEED.line.id); + }); + + it("presigns a document and confirms after upload", async () => { + const documents = createMemoryDocumentsStore(); + const app = createApp(envFor("admin"), createFakeDb(), { documents }); + const created = await app.request(`/api/invoices/${SEED.invoice.id}/documents`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ fileName: "inv.pdf", contentType: "application/pdf" }), + }); + expect(created.status).toBe(201); + const doc = (await created.json()) as { id: string; objectKey: string; uploadUrl: string }; + expect(doc.uploadUrl).toContain("presign=put"); + const missing = await app.request(`/api/documents/${doc.id}/confirmations`, { + method: "POST", + headers: jsonHeaders, + body: "{}", + }); + expect(missing.status).toBe(409); + documents.uploaded.add(doc.objectKey); + const confirmed = await app.request(`/api/documents/${doc.id}/confirmations`, { + method: "POST", + headers: jsonHeaders, + body: "{}", + }); + expect(confirmed.status).toBe(200); + const got = await app.request(`/api/documents/${doc.id}`); + expect(got.status).toBe(200); + await expect(got.json()).resolves.toMatchObject({ id: doc.id, fileName: "inv.pdf" }); + }); + + it("returns 403 when a viewer writes an invoice", async () => { + const app = createApp(envFor("viewer"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request("/api/invoices", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + vendorId: SEED.vendor.id, + invoiceNumber: "INV-9", + amount: "1.00", + dueDate: "2026-10-01", + }), + }); + expect(response.status).toBe(403); + expectEnvelope(await response.json(), "FORBIDDEN"); + }); +}); diff --git a/packages/api/src/routes/invoices.ts b/packages/api/src/routes/invoices.ts new file mode 100644 index 0000000..4f54d5d --- /dev/null +++ b/packages/api/src/routes/invoices.ts @@ -0,0 +1,463 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import type { Db, DbHandle } from "../db/client.js"; +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, closePendingSteps } from "../approvals.js"; +import { + asMoney, + asString, + caller, + firstById, + isDateOnly, + isUniqueViolation, + iso, + isUuid, + moneyCents, + newId, + optionalString, + parseJsonBody, + requireCan, + rowsOf, +} from "./helpers.js"; + +const PAYMENT_METHODS = ["check", "ach"] as const; + +type InvoiceRow = typeof invoices.$inferSelect; +type LineRow = typeof invoiceLines.$inferSelect; +type DocumentRow = typeof documents.$inferSelect; +type VendorRow = typeof vendors.$inferSelect; +type LineInput = { + description: string; + amount: string; + glAccountId: string | null; + departmentId: string | null; +}; + +function isPaymentMethod(value: string): value is (typeof PAYMENT_METHODS)[number] { + return (PAYMENT_METHODS as readonly string[]).includes(value); +} + +function toInvoice(row: InvoiceRow, lines: LineRow[]) { + return { + id: row.id, + vendorId: row.vendorId, + invoiceNumber: row.invoiceNumber, + amount: row.amount, + amountDue: row.amountDue, + dueDate: row.dueDate, + payDate: row.payDate, + sendPaymentOn: row.sendPaymentOn, + status: row.status, + paymentMethod: row.paymentMethod, + memo: row.memo, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + lines: lines.map(toLine), + }; +} + +function toLine(row: LineRow) { + return { + id: row.id, + invoiceId: row.invoiceId, + description: row.description, + amount: row.amount, + glAccountId: row.glAccountId, + departmentId: row.departmentId, + createdAt: iso(row.createdAt), + }; +} + +function toDocument( + row: DocumentRow, + urls: { uploadUrl?: string; uploadHeaders?: Record; downloadUrl?: string } = {}, +) { + return { + id: row.id, + invoiceId: row.invoiceId, + objectKey: row.objectKey, + contentType: row.contentType, + fileName: row.fileName, + uploadedByUserId: row.uploadedByUserId, + createdAt: iso(row.createdAt), + ...urls, + }; +} + +function parseLine(body: Record): LineInput | string { + const description = asString(body.description).trim(); + const amount = asMoney(body.amount); + if (!description || !amount) return "Line description and amount are required."; + const glAccountId = optionalString(body.glAccountId) ?? null; + const departmentId = optionalString(body.departmentId) ?? null; + if (glAccountId && !isUuid(glAccountId)) return "Invalid glAccountId."; + if (departmentId && !isUuid(departmentId)) return "Invalid departmentId."; + return { description, amount, glAccountId, departmentId }; +} + +function linesSumToAmount(lines: Array<{ amount: string }>, amount: string): boolean { + const sum = lines.reduce((total, line) => total + moneyCents(line.amount), 0); + return sum === moneyCents(amount); +} + +async function linesFor(handle: DbHandle, invoiceId: string): Promise { + const rows = await rowsOf(handle, invoiceLines); + return rows.filter((row) => row.invoiceId === invoiceId); +} + +async function activeDuplicate( + handle: DbHandle, + vendorId: string, + invoiceNumber: string, + exceptId?: string, +): Promise { + const rows = await rowsOf(handle, invoices); + return rows.some( + (row) => + row.vendorId === vendorId && + row.invoiceNumber === invoiceNumber && + row.status !== "void" && + row.id !== exceptId, + ); +} + +function safeFileName(value: string): string { + return ( + value + .replace(/[/\\]+/g, "_") + .replace(/^\.+/, "_") + .slice(0, 180) || "document" + ); +} + +export function createInvoiceRoutes(handle: Db, store: DocumentsStore) { + const routes = new Hono(); + + routes.get("/invoices", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const rows = await rowsOf(handle, invoices); + const allLines = await rowsOf(handle, invoiceLines); + return c.json({ + items: rows.map((row) => + toInvoice( + row, + allLines.filter((line) => line.invoiceId === row.id), + ), + ), + }); + }); + + routes.get("/invoices/: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 invoice id."); + const row = await firstById(handle, invoices, id); + if (!row) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + return c.json(toInvoice(row, await linesFor(handle, id))); + }); + + routes.post("/invoices", async (c) => { + const denied = requireCan(c, "write:invoices"); + if (denied) return denied; + const body = await parseJsonBody(c); + const vendorId = asString(body.vendorId); + const invoiceNumber = asString(body.invoiceNumber).trim(); + const amount = asMoney(body.amount); + const dueDate = asString(body.dueDate); + if (!isUuid(vendorId) || !invoiceNumber || !amount || !isDateOnly(dueDate)) { + return errorJson( + c, + 400, + "VALIDATION_ERROR", + "vendorId, invoiceNumber, amount, and dueDate are required.", + ); + } + const vendor = await firstById(handle, vendors, vendorId); + if (!vendor) return errorJson(c, 400, "VALIDATION_ERROR", "Vendor not found."); + const method = asString(body.paymentMethod, vendor.defaultPaymentMethod); + if (!isPaymentMethod(method)) { + return errorJson(c, 400, "VALIDATION_ERROR", "Invalid paymentMethod."); + } + const parsedLines: LineInput[] = []; + if (Array.isArray(body.lines)) { + for (const raw of body.lines) { + if (!raw || typeof raw !== "object") { + return errorJson(c, 400, "VALIDATION_ERROR", "Each line must be an object."); + } + const parsed = parseLine(raw as Record); + if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed); + parsedLines.push(parsed); + } + if (!linesSumToAmount(parsedLines, amount)) { + return errorJson( + c, + 400, + "VALIDATION_ERROR", + "Line amounts must sum to the invoice amount.", + ); + } + } + if (await activeDuplicate(handle, vendorId, invoiceNumber)) { + return errorJson( + c, + 409, + "CONFLICT", + "An active invoice already uses this vendor and invoice number.", + ); + } + let latest: InvoiceRow | undefined; + let currentLines: LineRow[] = []; + try { + await handle.db.transaction(async (tx) => { + const scoped: DbHandle = { db: tx }; + const [row] = await tx + .insert(invoices) + .values({ + vendorId, + invoiceNumber, + amount, + amountDue: amount, + dueDate, + paymentMethod: method, + memo: asString(body.memo), + status: "pending_approval", + }) + .returning(); + for (const line of parsedLines) { + await tx + .insert(invoiceLines) + .values({ invoiceId: row.id, ...line }) + .returning(); + } + latest = await applyMatchingPolicy(scoped, row, caller(c).id); + currentLines = await linesFor(scoped, row.id); + }); + } catch (error) { + if (isUniqueViolation(error)) { + return errorJson( + c, + 409, + "CONFLICT", + "An active invoice already uses this vendor and invoice number.", + ); + } + throw error; + } + if (!latest) { + return errorJson(c, 500, "INTERNAL_ERROR", "Invoice create did not complete."); + } + return c.json(toInvoice(latest, currentLines), 201); + }); + + routes.patch("/invoices/:id", async (c) => { + const denied = requireCan(c, "write:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); + 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 = { id, updatedAt: new Date() }; + if (body.vendorId !== undefined) { + const vendorId = asString(body.vendorId); + if (!isUuid(vendorId)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid vendorId."); + patch.vendorId = vendorId; + } + if (body.invoiceNumber !== undefined) { + const invoiceNumber = asString(body.invoiceNumber).trim(); + if (!invoiceNumber) + return errorJson(c, 400, "VALIDATION_ERROR", "invoiceNumber is required."); + patch.invoiceNumber = invoiceNumber; + } + if (body.amount !== undefined) { + const amount = asMoney(body.amount); + if (!amount) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid amount."); + patch.amount = amount; + patch.amountDue = amount; + } + if (body.dueDate !== undefined) { + const dueDate = asString(body.dueDate); + if (!isDateOnly(dueDate)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid dueDate."); + patch.dueDate = dueDate; + } + if (body.payDate !== undefined) { + const payDate = optionalString(body.payDate) ?? null; + if (payDate && !isDateOnly(payDate)) + return errorJson(c, 400, "VALIDATION_ERROR", "Invalid payDate."); + patch.payDate = payDate; + } + if (body.sendPaymentOn !== undefined) { + const sendPaymentOn = optionalString(body.sendPaymentOn) ?? null; + if (sendPaymentOn && !isDateOnly(sendPaymentOn)) { + return errorJson(c, 400, "VALIDATION_ERROR", "Invalid sendPaymentOn."); + } + patch.sendPaymentOn = sendPaymentOn; + } + if (body.status !== undefined) { + const status = asString(body.status); + if (status !== "void") { + return errorJson( + c, + 400, + "VALIDATION_ERROR", + "PATCH may only set status to void. Approval and payment statuses go through decision routes.", + ); + } + patch.status = "void"; + } + if (body.paymentMethod !== undefined) { + const method = asString(body.paymentMethod); + if (!isPaymentMethod(method)) + return errorJson(c, 400, "VALIDATION_ERROR", "Invalid paymentMethod."); + patch.paymentMethod = method; + } + if (body.memo !== undefined) patch.memo = asString(body.memo); + const nextVendor = patch.vendorId ?? existing.vendorId; + const nextNumber = patch.invoiceNumber ?? existing.invoiceNumber; + const nextStatus = patch.status ?? existing.status; + if (nextStatus !== "void" && (await activeDuplicate(handle, nextVendor, nextNumber, id))) { + return errorJson( + c, + 409, + "CONFLICT", + "An active invoice already uses this vendor and invoice number.", + ); + } + const nextAmount = patch.amount ?? existing.amount; + const currentLines = await linesFor(handle, id); + if (currentLines.length > 0 && !linesSumToAmount(currentLines, nextAmount)) { + return errorJson(c, 400, "VALIDATION_ERROR", "Line amounts must sum to the invoice amount."); + } + let row: InvoiceRow; + try { + if (patch.status === "void") { + await closePendingSteps(handle, id, caller(c).id); + } + [row] = await handle.db.update(invoices).set(patch).where(eq(invoices.id, id)).returning(); + } catch (error) { + if (isUniqueViolation(error)) { + return errorJson( + c, + 409, + "CONFLICT", + "An active invoice already uses this vendor and invoice number.", + ); + } + throw error; + } + return c.json(toInvoice(row, currentLines)); + }); + + routes.post("/invoices/:id/lines", async (c) => { + const denied = requireCan(c, "write: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 parsed = parseLine(await parseJsonBody(c)); + if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed); + const [row] = await handle.db + .insert(invoiceLines) + .values({ invoiceId: id, ...parsed }) + .returning(); + return c.json(toLine(row), 201); + }); + + routes.put("/invoices/:id/lines", async (c) => { + const denied = requireCan(c, "write: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 = await parseJsonBody(c); + if (!Array.isArray(body.items)) { + return errorJson(c, 400, "VALIDATION_ERROR", "items must be an array of lines."); + } + const parsedLines: LineInput[] = []; + for (const raw of body.items) { + if (!raw || typeof raw !== "object") { + return errorJson(c, 400, "VALIDATION_ERROR", "Each line must be an object."); + } + const parsed = parseLine(raw as Record); + if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed); + parsedLines.push(parsed); + } + if (!linesSumToAmount(parsedLines, invoice.amount)) { + return errorJson(c, 400, "VALIDATION_ERROR", "Line amounts must sum to the invoice amount."); + } + const created: LineRow[] = []; + await handle.db.transaction(async (tx) => { + await tx.delete(invoiceLines).where(eq(invoiceLines.invoiceId, id)); + for (const line of parsedLines) { + const [row] = await tx + .insert(invoiceLines) + .values({ invoiceId: id, ...line }) + .returning(); + created.push(row); + } + }); + return c.json({ items: created.map(toLine) }); + }); + + routes.post("/invoices/:id/documents", async (c) => { + const denied = requireCan(c, "write: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 = await parseJsonBody(c); + const fileName = safeFileName(asString(body.fileName).trim()); + const contentType = asString(body.contentType, "application/pdf").trim() || "application/pdf"; + if (!asString(body.fileName).trim()) { + return errorJson(c, 400, "VALIDATION_ERROR", "fileName is required."); + } + const documentId = newId(); + const objectKey = `invoices/${id}/${documentId}/${fileName}`; + const [row] = await handle.db + .insert(documents) + .values({ + id: documentId, + invoiceId: id, + objectKey, + contentType, + fileName, + uploadedByUserId: caller(c).id, + }) + .returning(); + const put = await store.presignPut({ objectKey, contentType }); + return c.json(toDocument(row, { uploadUrl: put.url, uploadHeaders: put.headers }), 201); + }); + + routes.post("/documents/:id/confirmations", async (c) => { + const denied = requireCan(c, "write:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid document id."); + const row = await firstById(handle, documents, id); + if (!row) return errorJson(c, 404, "NOT_FOUND", "Document not found."); + if (!(await store.objectExists(row.objectKey))) { + return errorJson(c, 409, "CONFLICT", "Object has not been uploaded."); + } + return c.json(toDocument(row, { downloadUrl: await store.presignGet(row.objectKey) })); + }); + + routes.get("/documents/: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 document id."); + const row = await firstById(handle, documents, id); + if (!row) return errorJson(c, 404, "NOT_FOUND", "Document not found."); + return c.json(toDocument(row, { downloadUrl: await store.presignGet(row.objectKey) })); + }); + + return routes; +} diff --git a/packages/api/src/routes/master-data.test.ts b/packages/api/src/routes/master-data.test.ts new file mode 100644 index 0000000..94fa80e --- /dev/null +++ b/packages/api/src/routes/master-data.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../app.js"; +import { loadEnv } from "../env.js"; +import type { ErrorEnvelope } from "../http.js"; +import { createFakeDb, emptyStore, SEED } from "../test/fake-db.js"; + +function expectEnvelope(body: unknown, code: string) { + const envelope = body as ErrorEnvelope; + expect(envelope.error.code).toBe(code); +} + +function adminEnv() { + 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: "admin", + }); +} + +function viewerEnv() { + 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: "viewer", + }); +} + +describe("master data stubs", () => { + it("lists and gets seeded vendors", async () => { + const app = createApp(adminEnv(), createFakeDb()); + const list = await app.request("/api/vendors"); + expect(list.status).toBe(200); + const listed = (await list.json()) as { items: Array<{ id: string; name: string }> }; + expect(listed.items[0]?.id).toBe(SEED.vendor.id); + const get = await app.request(`/api/vendors/${SEED.vendor.id}`); + expect(get.status).toBe(200); + await expect(get.json()).resolves.toMatchObject({ id: SEED.vendor.id, name: SEED.vendor.name }); + }); + + it("creates a vendor and returns 201", async () => { + const app = createApp(adminEnv(), createFakeDb()); + const response = await app.request("/api/vendors", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:3000" }, + body: JSON.stringify({ name: "Harbor Maintenance", defaultPaymentMethod: "ach" }), + }); + expect(response.status).toBe(201); + await expect(response.json()).resolves.toMatchObject({ + name: "Harbor Maintenance", + defaultPaymentMethod: "ach", + }); + }); + + it("returns 404 for a missing vendor", async () => { + const store = emptyStore(); + store.vendors = []; + const app = createApp(adminEnv(), createFakeDb(store)); + const response = await app.request(`/api/vendors/${SEED.vendor.id}`); + expect(response.status).toBe(404); + expectEnvelope(await response.json(), "NOT_FOUND"); + }); + + it("returns 403 when a non-admin writes vendors", async () => { + const app = createApp(viewerEnv(), createFakeDb()); + const response = await app.request("/api/vendors", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:3000" }, + body: JSON.stringify({ name: "Nope" }), + }); + expect(response.status).toBe(403); + expectEnvelope(await response.json(), "FORBIDDEN"); + }); + + it("lists GL accounts and departments", async () => { + const app = createApp(adminEnv(), createFakeDb()); + const gl = await app.request("/api/gl-accounts"); + expect(gl.status).toBe(200); + const glBody = (await gl.json()) as { items: Array<{ code: string }> }; + expect(glBody.items[0]?.code).toBe("6100"); + const departments = await app.request("/api/departments"); + expect(departments.status).toBe(200); + const deptBody = (await departments.json()) as { items: Array<{ code: string }> }; + expect(deptBody.items[0]?.code).toBe("OPS"); + }); + + it("updates a user role", async () => { + const app = createApp(adminEnv(), createFakeDb()); + const response = await app.request(`/api/users/${SEED.user.id}`, { + method: "PATCH", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:3000" }, + body: JSON.stringify({ role: "approver" }), + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ role: "approver" }); + }); +}); diff --git a/packages/api/src/routes/me.ts b/packages/api/src/routes/me.ts index e96b02f..1e98f08 100644 --- a/packages/api/src/routes/me.ts +++ b/packages/api/src/routes/me.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import type { AppBindings } from "../auth/middleware.js"; import { can } from "../auth/rbac.js"; +import { errorJson } from "../http.js"; export function createMeRoutes() { const routes = new Hono(); @@ -8,7 +9,7 @@ export function createMeRoutes() { routes.get("/me", (c) => { const user = c.get("user"); if (!can(user.role, "read:me")) { - return c.json({ error: "Forbidden." }, 403); + return errorJson(c, 403, "FORBIDDEN", "Forbidden."); } return c.json({ diff --git a/packages/api/src/routes/users.ts b/packages/api/src/routes/users.ts new file mode 100644 index 0000000..7e2e1a9 --- /dev/null +++ b/packages/api/src/routes/users.ts @@ -0,0 +1,61 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import type { Db } from "../db/client.js"; +import { users } from "../db/schema/index.js"; +import type { AppBindings } from "../auth/middleware.js"; +import { errorJson } from "../http.js"; +import { iso, isUuid, parseJsonBody, requireCan } from "./helpers.js"; +import { isUserRole } from "../env.js"; + +function toUser(row: typeof users.$inferSelect) { + return { + id: row.id, + email: row.email, + name: row.name, + role: row.role, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + }; +} + +export function createUserRoutes(handle: Db) { + const routes = new Hono(); + + routes.get("/users", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const rows = await handle.db.select().from(users); + return c.json({ items: rows.map(toUser) }); + }); + + routes.get("/users/: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 user id."); + const row = await handle.db.query.users.findFirst({ where: eq(users.id, id) }); + if (!row) return errorJson(c, 404, "NOT_FOUND", "User not found."); + return c.json(toUser(row)); + }); + + routes.patch("/users/: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 user id."); + const existing = await handle.db.query.users.findFirst({ where: eq(users.id, id) }); + if (!existing) return errorJson(c, 404, "NOT_FOUND", "User not found."); + const body = await parseJsonBody(c); + if (typeof body.role !== "string" || !isUserRole(body.role)) { + return errorJson(c, 400, "VALIDATION_ERROR", "A valid role is required."); + } + const [row] = await handle.db + .update(users) + .set({ role: body.role, updatedAt: new Date() }) + .where(eq(users.id, id)) + .returning(); + return c.json(toUser(row)); + }); + + return routes; +} diff --git a/packages/api/src/routes/vendors.ts b/packages/api/src/routes/vendors.ts new file mode 100644 index 0000000..8d819ff --- /dev/null +++ b/packages/api/src/routes/vendors.ts @@ -0,0 +1,97 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import type { Db } from "../db/client.js"; +import { vendors } from "../db/schema/index.js"; +import type { AppBindings } from "../auth/middleware.js"; +import { errorJson } from "../http.js"; +import { asString, iso, isUuid, optionalString, parseJsonBody, requireCan } from "./helpers.js"; + +const PAYMENT_METHODS = ["check", "ach"] as const; +type PaymentMethod = (typeof PAYMENT_METHODS)[number]; + +function isPaymentMethod(value: string): value is PaymentMethod { + return (PAYMENT_METHODS as readonly string[]).includes(value); +} + +function toVendor(row: typeof vendors.$inferSelect) { + return { + id: row.id, + name: row.name, + email: row.email, + defaultPaymentMethod: row.defaultPaymentMethod, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + }; +} + +export function createVendorRoutes(handle: Db) { + const routes = new Hono(); + + routes.get("/vendors", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const rows = await handle.db.select().from(vendors); + return c.json({ items: rows.map(toVendor) }); + }); + + routes.get("/vendors/: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 vendor id."); + const row = await handle.db.query.vendors.findFirst({ where: eq(vendors.id, id) }); + if (!row) return errorJson(c, 404, "NOT_FOUND", "Vendor not found."); + return c.json(toVendor(row)); + }); + + routes.post("/vendors", 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 method = asString(body.defaultPaymentMethod, "check"); + if (!isPaymentMethod(method)) { + return errorJson(c, 400, "VALIDATION_ERROR", "Invalid defaultPaymentMethod."); + } + const [row] = await handle.db + .insert(vendors) + .values({ + name, + email: optionalString(body.email) ?? null, + defaultPaymentMethod: method, + }) + .returning(); + return c.json(toVendor(row), 201); + }); + + routes.patch("/vendors/: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 vendor id."); + const existing = await handle.db.query.vendors.findFirst({ where: eq(vendors.id, id) }); + if (!existing) return errorJson(c, 404, "NOT_FOUND", "Vendor not found."); + const body = await parseJsonBody(c); + const patch: Partial = { 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 (body.email !== undefined) { + patch.email = optionalString(body.email) ?? null; + } + if (body.defaultPaymentMethod !== undefined) { + const method = asString(body.defaultPaymentMethod); + if (!isPaymentMethod(method)) { + return errorJson(c, 400, "VALIDATION_ERROR", "Invalid defaultPaymentMethod."); + } + patch.defaultPaymentMethod = method; + } + const [row] = await handle.db.update(vendors).set(patch).where(eq(vendors.id, id)).returning(); + return c.json(toVendor(row)); + }); + + return routes; +} diff --git a/packages/api/src/test/fake-db.ts b/packages/api/src/test/fake-db.ts new file mode 100644 index 0000000..1e5a843 --- /dev/null +++ b/packages/api/src/test/fake-db.ts @@ -0,0 +1,261 @@ +import { getTableName } from "drizzle-orm"; +import { vi } from "vitest"; +import type { Db } from "../db/client.js"; +import type { UserRole } from "../env.js"; + +export const SEED = { + user: { + id: "11111111-1111-4111-8111-111111111111", + cognitoSub: "seed-sub-admin", + email: "admin@seahavenind.com", + name: "Dev Admin", + role: "admin" as UserRole, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }, + vendor: { + id: "55555555-5555-4555-8555-555555555555", + name: "Acme Facilities Supply", + email: "billing@acmefacilities.example", + defaultPaymentMethod: "check" as const, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }, + gl: { + id: "66666666-6666-4666-8666-666666666666", + code: "6100", + name: "Facilities Expense", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }, + department: { + id: "77777777-7777-4777-8777-777777777777", + code: "OPS", + name: "Operations", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }, + invoice: { + id: "88888888-8888-4888-8888-888888888888", + vendorId: "55555555-5555-4555-8555-555555555555", + invoiceNumber: "INV-1001", + amount: "1250.00", + amountDue: "1250.00", + dueDate: "2026-09-01", + payDate: null as string | null, + sendPaymentOn: null as string | null, + status: "pending_approval" as const, + paymentMethod: "check" as const, + memo: "Seed invoice for local smoke.", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }, + line: { + id: "99999999-9999-4999-8999-999999999999", + invoiceId: "88888888-8888-4888-8888-888888888888", + description: "Monthly maintenance", + amount: "1250.00", + glAccountId: "66666666-6666-4666-8666-666666666666", + departmentId: "77777777-7777-4777-8777-777777777777", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }, + document: { + id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + invoiceId: "88888888-8888-4888-8888-888888888888", + objectKey: "seed/inv-1001.pdf", + contentType: "application/pdf", + fileName: "inv-1001.pdf", + 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 = { + users: Array; + vendors: Array; + glAccounts: Array; + departments: Array; + invoices: Array; + invoiceLines: Array; + documents: Array; + approvalPolicies: Array; + approvalSteps: Array; + invoiceComments: Array; + activityLog: Array; +}; + +export function emptyStore(): Store { + return { + users: [{ ...SEED.user }], + vendors: [{ ...SEED.vendor }], + glAccounts: [{ ...SEED.gl }], + departments: [{ ...SEED.department }], + invoices: [{ ...SEED.invoice }], + invoiceLines: [{ ...SEED.line }], + documents: [{ ...SEED.document }], + approvalPolicies: [{ ...SEED.policy }], + approvalSteps: [{ ...SEED.step }], + invoiceComments: [{ ...SEED.comment }], + activityLog: [{ ...SEED.activity }], + }; +} + +function rowsFor(store: Store, table: unknown): Array> { + switch (getTableName(table as never)) { + case "users": + return store.users; + case "vendors": + return store.vendors; + case "gl_accounts": + return store.glAccounts; + case "departments": + return store.departments; + case "invoices": + return store.invoices; + case "invoice_lines": + return store.invoiceLines; + case "documents": + return store.documents; + case "approval_policies": + return store.approvalPolicies; + case "approval_steps": + return store.approvalSteps; + case "invoice_comments": + return store.invoiceComments; + case "activity_log": + return store.activityLog; + default: + return []; + } +} + +function findById(rows: Array>, id: string) { + return rows.find((row) => row.id === id) ?? null; +} + +export function createFakeDb(store: Store = emptyStore()): Db { + const queryFind = (tableName: keyof Store) => ({ + findFirst: vi.fn(async (opts?: { where?: unknown }) => { + const rows = store[tableName] as Array>; + if (!opts) return rows[0] ?? null; + // Routes always look up by primary key; return the first seeded row or null via tests mutating store. + return rows[0] ?? null; + }), + findMany: vi.fn(async () => store[tableName]), + }); + + const db = { + execute: vi.fn(async () => []), + select: vi.fn(() => ({ + from: vi.fn(async (table: unknown) => rowsFor(store, table)), + })), + query: { + users: queryFind("users"), + vendors: queryFind("vendors"), + glAccounts: queryFind("glAccounts"), + departments: queryFind("departments"), + invoices: queryFind("invoices"), + invoiceLines: queryFind("invoiceLines"), + documents: queryFind("documents"), + approvalPolicies: queryFind("approvalPolicies"), + approvalSteps: queryFind("approvalSteps"), + invoiceComments: queryFind("invoiceComments"), + activityLog: queryFind("activityLog"), + }, + insert: vi.fn((table: unknown) => ({ + values: vi.fn((value: Record) => ({ + returning: vi.fn(async () => { + const now = new Date(); + const row = { + id: typeof value.id === "string" ? value.id : crypto.randomUUID(), + createdAt: now, + updatedAt: now, + ...value, + }; + rowsFor(store, table).push(row); + return [row]; + }), + })), + })), + update: vi.fn((table: unknown) => ({ + set: vi.fn((patch: Record) => ({ + where: vi.fn(() => ({ + returning: vi.fn(async () => { + const rows = rowsFor(store, table); + const current = + (typeof patch.id === "string" && rows.find((row) => row.id === patch.id)) || rows[0]; + if (!current) return []; + Object.assign(current, patch); + return [current]; + }), + })), + })), + })), + delete: vi.fn((table: unknown) => ({ + where: vi.fn(async () => { + const rows = rowsFor(store, table); + rows.splice(0, rows.length); + }), + })), + transaction: vi.fn(async (callback: (tx: never) => Promise) => { + const snapshot = structuredClone(store) as Store; + try { + return await callback(db as never); + } catch (error) { + (Object.keys(store) as Array).forEach((key) => { + const rows = store[key] as unknown as Array>; + rows.splice( + 0, + rows.length, + ...(snapshot[key] as unknown as Array>), + ); + }); + throw error; + } + }), + }; + + return { + driver: "postgres", + pool: { end: vi.fn(async () => undefined) } as never, + db: db as never, + }; +} + +export { findById }; diff --git a/packages/api/tsconfig.build.json b/packages/api/tsconfig.build.json index cf0eba4..fc94193 100644 --- a/packages/api/tsconfig.build.json +++ b/packages/api/tsconfig.build.json @@ -3,5 +3,5 @@ "compilerOptions": { "noEmit": false }, - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/test/**"] } diff --git a/redocly.yaml b/redocly.yaml index 4c640d9..6b2c306 100644 --- a/redocly.yaml +++ b/redocly.yaml @@ -21,9 +21,9 @@ rules: assertions: defined: true operation-4xx-response: error - # Off: the live contract is {"error": string} as plain application/json - # (serialization.py error_response). Adopting RFC 7807 would be a runtime - # + SHOC-contract change, decided against 2026-07-24. + # Off: the live contract is {"error": { code, message, correlationId }} as + # plain application/json, matching the portal BFF envelope. RFC 7807 is not + # the AP contract. operation-4xx-problem-details-rfc7807: off operation-operationId: error rule/operationId-casing: @@ -64,8 +64,15 @@ rules: - docs - openapi.json - health + - ready - me - api + - auth + - login + - callback + - refresh + - logout + - inbox paths-kebab-case: error no-invalid-schema-examples: error # No schema-properties casing rule: property names mirror the DynamoDB @@ -96,12 +103,9 @@ rules: - application/json - text/html no-server-example.com: error - rule/no-server-localhost: - subject: - type: Server - property: url - assertions: - notPattern: /(localhost|127.0.0.1) + # This batch documents the local API only (http://127.0.0.1:8787). Production + # hostname documentation is AP-12. + rule/no-server-localhost: off operation-singular-tag: error operation-tag-defined: error rule/tag-description: diff --git a/src/api/client.test.ts b/src/api/client.test.ts new file mode 100644 index 0000000..f35b812 --- /dev/null +++ b/src/api/client.test.ts @@ -0,0 +1,58 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { apiFetch, readApiJson } from "@/api/client"; + +function walk(dir: string): string[] { + const entries = readdirSync(dir); + const files: string[] = []; + for (const entry of entries) { + const full = join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + files.push(...walk(full)); + } else if (full.endsWith(".ts") || full.endsWith(".tsx")) { + files.push(full); + } + } + return files; +} + +describe("unused SPA API client", () => { + it("sends credentials and never sets Authorization", async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stage: "local", sha: "x" }))); + vi.stubGlobal("fetch", fetchMock); + await apiFetch("/api/health", { headers: { Authorization: "Bearer leaked" } }); + expect(fetchMock).toHaveBeenCalledWith( + "/api/health", + expect.objectContaining({ credentials: "include" }), + ); + const init = (fetchMock.mock.calls[0] as unknown as [string, RequestInit])[1]; + expect(init.credentials).toBe("include"); + const headers = new Headers(init.headers); + expect(headers.get("Authorization")).toBeNull(); + expect(headers.get("Accept")).toBe("application/json"); + vi.unstubAllGlobals(); + }); + + it("is not imported from pages, domain, or mocks", () => { + const roots = ["src/pages", "src/domain", "src/mocks"].map((dir) => join(process.cwd(), dir)); + const hits: string[] = []; + for (const root of roots) { + for (const file of walk(root)) { + const text = readFileSync(file, "utf8"); + if (text.includes("@/api/client") || text.includes("src/api/client")) { + hits.push(file); + } + } + } + expect(hits).toEqual([]); + }); + + it("parses JSON success bodies", async () => { + const body = await readApiJson<{ stage: string }>( + new Response(JSON.stringify({ stage: "local" }), { status: 200 }), + ); + expect(body.stage).toBe("local"); + }); +}); diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..6467cda --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,87 @@ +import type { ErrorEnvelope } from "@/api/types"; + +function pathnameOf(input: RequestInfo | URL): string { + const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + try { + return new URL(raw, "http://local.invalid").pathname; + } catch { + return raw.split("?")[0] ?? raw; + } +} + +function skipRefresh(input: RequestInfo | URL): boolean { + const path = pathnameOf(input); + return ( + path === "/api/auth/login" || + path === "/api/auth/callback" || + path === "/api/auth/refresh" || + path === "/api/auth/logout" + ); +} + +let refreshInFlight: Promise | null = null; + +async function refreshSession(): Promise { + if (!refreshInFlight) { + refreshInFlight = fetch("/api/auth/refresh", { + method: "POST", + credentials: "include", + headers: { Accept: "application/json" }, + }) + .then((response) => response.status === 204) + .catch(() => false) + .finally(() => { + refreshInFlight = null; + }); + } + return refreshInFlight; +} + +export async function apiFetch( + input: RequestInfo | URL, + init: RequestInit = {}, +): Promise { + const headers = new Headers(init.headers); + if (!headers.has("Accept")) headers.set("Accept", "application/json"); + headers.delete("Authorization"); + const requestInit: RequestInit = { ...init, credentials: "include", headers }; + const response = await fetch(input, requestInit); + if ((response.status !== 401 && response.status !== 403) || skipRefresh(input)) { + return response; + } + const refreshed = await refreshSession(); + if (refreshed) return fetch(input, requestInit); + return response; +} + +export class ApiError extends Error { + readonly status: number; + readonly code?: string; + + constructor(message: string, status: number, code?: string) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + } +} + +export async function readApiJson(response: Response): Promise { + const text = await response.text(); + let body: T & Partial; + try { + body = JSON.parse(text) as T & Partial; + } catch { + throw response.ok + ? new Error("Invalid JSON from API") + : new ApiError(`HTTP ${response.status}`, response.status); + } + if (!response.ok) { + throw new ApiError( + body.error?.message || `HTTP ${response.status}`, + response.status, + body.error?.code, + ); + } + return body; +} diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..e5b36b0 --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,123 @@ +export type ErrorEnvelope = { + error: { + code: string; + message: string; + correlationId: string; + }; +}; + +export type HealthResponse = { + stage: string; + sha: string; +}; + +export type MeResponse = { + id: string; + email: string; + name: string; + role: "admin" | "ap_processor" | "approver" | "viewer"; +}; + +export type Vendor = { + id: string; + name: string; + email: string | null; + defaultPaymentMethod: "check" | "ach"; + createdAt: string; + updatedAt: string; +}; + +export type GlAccount = { + id: string; + code: string; + name: string; + createdAt: string; + updatedAt: string; +}; + +export type Department = { + id: string; + code: string; + name: string; + createdAt: string; + updatedAt: string; +}; + +export type User = { + id: string; + email: string; + name: string; + role: MeResponse["role"]; + createdAt: string; + updatedAt: string; +}; + +export type InvoiceLine = { + id: string; + invoiceId: string; + description: string; + amount: string; + glAccountId: string | null; + departmentId: string | null; + createdAt: string; +}; + +export type Invoice = { + id: string; + vendorId: string; + invoiceNumber: string; + amount: string; + amountDue: string; + dueDate: string; + payDate: string | null; + sendPaymentOn: string | null; + status: "pending_approval" | "approved" | "scheduled" | "paid" | "rejected" | "void"; + paymentMethod: "check" | "ach"; + memo: string; + createdAt: string; + updatedAt: string; + lines: InvoiceLine[]; +}; + +export type Document = { + id: string; + invoiceId: string | null; + objectKey: string; + contentType: string; + fileName: string; + uploadedByUserId: string | null; + createdAt: string; + uploadUrl?: string; + uploadHeaders?: Record; + downloadUrl?: string; +}; + +export type ApiPaths = { + "/api/health": { + get: { response: HealthResponse }; + }; + "/api/me": { + get: { response: MeResponse }; + }; + "/api/vendors": { + get: { response: { items: Vendor[] } }; + }; + "/api/gl-accounts": { + get: { response: { items: GlAccount[] } }; + }; + "/api/departments": { + get: { response: { items: Department[] } }; + }; + "/api/users": { + get: { response: { items: User[] } }; + }; + "/api/invoices": { + get: { response: { items: Invoice[] } }; + }; + "/api/documents/{id}": { + get: { response: Document }; + }; + "/api/inbox": { + get: { response: { items: Array<{ id: string; invoiceId: string; status: string }> } }; + }; +};