From ebee62acd023f40a0178058c9963a2bc14e8d575 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 12:44:51 -0400 Subject: [PATCH] feat(api): add master-data OpenAPI and Hono stubs --- packages/api/openapi/components/schemas.yaml | 116 ++++++++++++ packages/api/openapi/openapi.yaml | 26 +++ .../api/openapi/paths/departments-id.yaml | 82 +++++++++ packages/api/openapi/paths/departments.yaml | 68 +++++++ .../api/openapi/paths/gl-accounts-id.yaml | 82 +++++++++ packages/api/openapi/paths/gl-accounts.yaml | 68 +++++++ 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/src/app.ts | 8 + packages/api/src/routes/departments.ts | 80 ++++++++ packages/api/src/routes/gl-accounts.ts | 78 ++++++++ packages/api/src/routes/helpers.ts | 49 +++++ packages/api/src/routes/master-data.test.ts | 102 +++++++++++ packages/api/src/routes/users.ts | 61 +++++++ packages/api/src/routes/vendors.ts | 97 ++++++++++ packages/api/src/test/fake-db.ts | 172 ++++++++++++++++++ packages/api/tsconfig.build.json | 2 +- src/api/types.ts | 46 +++++ 20 files changed, 1399 insertions(+), 1 deletion(-) 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/gl-accounts-id.yaml create mode 100644 packages/api/openapi/paths/gl-accounts.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/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/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 diff --git a/packages/api/openapi/components/schemas.yaml b/packages/api/openapi/components/schemas.yaml index 994feb7..4d1ea11 100644 --- a/packages/api/openapi/components/schemas.yaml +++ b/packages/api/openapi/components/schemas.yaml @@ -81,3 +81,119 @@ 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. diff --git a/packages/api/openapi/openapi.yaml b/packages/api/openapi/openapi.yaml index c3fab19..2ecdda1 100644 --- a/packages/api/openapi/openapi.yaml +++ b/packages/api/openapi/openapi.yaml @@ -13,6 +13,8 @@ tags: description: Liveness and readiness checks for the API process. - name: Session description: Cookie session via Cognito hosted UI, plus caller identity after upsert. + - name: Master data + description: Vendors, GL accounts, departments, and user role updates. paths: /api/health: $ref: ./paths/health.yaml @@ -28,6 +30,22 @@ paths: $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 components: securitySchemes: cookieAuth: @@ -41,5 +59,13 @@ components: $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 security: - cookieAuth: [] 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/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/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/src/app.ts b/packages/api/src/app.ts index 83f4b8f..a75016e 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -5,6 +5,10 @@ import { createAuthMiddleware, type AppBindings, type AuthDeps } from "./auth/mi 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 { errorJson } from "./http.js"; import { cloudFrontOriginAllowed } from "./auth/origin-verify.js"; import { csrfAllowed, isMutating } from "./auth/oauth.js"; @@ -40,6 +44,10 @@ export function createApp(env: ApiEnv, handle: Db, deps: AppDeps = {}) { 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)); app.route("/api", api); app.notFound((c) => errorJson(c, 404, "NOT_FOUND", "Not found.")); 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/helpers.ts b/packages/api/src/routes/helpers.ts new file mode 100644 index 0000000..c44d41d --- /dev/null +++ b/packages/api/src/routes/helpers.ts @@ -0,0 +1,49 @@ +import { randomUUID } from "node:crypto"; +import type { Context } from "hono"; +import type { UserRole } from "../env.js"; +import { can, type RbacAction } from "../auth/rbac.js"; +import { 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 function parseJsonBody(c: Context): Promise> { + return c.req.json>(); +} + +export type { UserRole }; 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/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..eaae40e --- /dev/null +++ b/packages/api/src/test/fake-db.ts @@ -0,0 +1,172 @@ +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"), + }, +}; + +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: [], + invoiceLines: [], + documents: [], + approvalPolicies: [], + approvalSteps: [], + invoiceComments: [], + activityLog: [], + }; +} + +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 = rows[0]; + if (!current) return []; + Object.assign(current, patch); + return [current]; + }), + })), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(async () => undefined), + })), + }; + + 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/src/api/types.ts b/src/api/types.ts index e8c1837..510f9c5 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -18,6 +18,40 @@ export type MeResponse = { 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 ApiPaths = { "/api/health": { get: { response: HealthResponse }; @@ -25,4 +59,16 @@ export type ApiPaths = { "/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[] } }; + }; };