feat(api): add master-data OpenAPI and Hono stubs

This commit is contained in:
Adam Moussa 2026-09-22 12:44:51 -04:00
parent 1c295d854d
commit ebee62acd0
No known key found for this signature in database
20 changed files with 1399 additions and 1 deletions

View file

@ -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.

View file

@ -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: []

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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."));

View file

@ -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<AppBindings>();
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<typeof departments.$inferInsert> = { 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;
}

View file

@ -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<AppBindings>();
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<typeof glAccounts.$inferInsert> = { 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;
}

View file

@ -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<AppBindings>, 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<AppBindings>) {
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<Record<string, unknown>> {
return c.req.json<Record<string, unknown>>();
}
export type { UserRole };

View file

@ -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" });
});
});

View file

@ -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<AppBindings>();
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;
}

View file

@ -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<AppBindings>();
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<typeof vendors.$inferInsert> = { 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;
}

View file

@ -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<typeof SEED.user>;
vendors: Array<typeof SEED.vendor>;
glAccounts: Array<typeof SEED.gl>;
departments: Array<typeof SEED.department>;
invoices: Array<Record<string, unknown>>;
invoiceLines: Array<Record<string, unknown>>;
documents: Array<Record<string, unknown>>;
approvalPolicies: Array<Record<string, unknown>>;
approvalSteps: Array<Record<string, unknown>>;
invoiceComments: Array<Record<string, unknown>>;
activityLog: Array<Record<string, unknown>>;
};
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<Record<string, unknown>> {
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<Record<string, unknown>>, 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<Record<string, unknown>>;
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<string, unknown>) => ({
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<string, unknown>) => ({
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 };

View file

@ -3,5 +3,5 @@
"compilerOptions": {
"noEmit": false
},
"exclude": ["src/**/*.test.ts"]
"exclude": ["src/**/*.test.ts", "src/test/**"]
}

View file

@ -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[] } };
};
};