mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-10-01 09:43:17 +00:00
feat(api): add master-data OpenAPI and Hono stubs
This commit is contained in:
parent
1c295d854d
commit
ebee62acd0
20 changed files with 1399 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
|
|
|
|||
82
packages/api/openapi/paths/departments-id.yaml
Normal file
82
packages/api/openapi/paths/departments-id.yaml
Normal 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
|
||||
68
packages/api/openapi/paths/departments.yaml
Normal file
68
packages/api/openapi/paths/departments.yaml
Normal 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
|
||||
82
packages/api/openapi/paths/gl-accounts-id.yaml
Normal file
82
packages/api/openapi/paths/gl-accounts-id.yaml
Normal 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
|
||||
68
packages/api/openapi/paths/gl-accounts.yaml
Normal file
68
packages/api/openapi/paths/gl-accounts.yaml
Normal 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
|
||||
81
packages/api/openapi/paths/users-id.yaml
Normal file
81
packages/api/openapi/paths/users-id.yaml
Normal 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
|
||||
24
packages/api/openapi/paths/users.yaml
Normal file
24
packages/api/openapi/paths/users.yaml
Normal 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
|
||||
86
packages/api/openapi/paths/vendors-id.yaml
Normal file
86
packages/api/openapi/paths/vendors-id.yaml
Normal 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
|
||||
72
packages/api/openapi/paths/vendors.yaml
Normal file
72
packages/api/openapi/paths/vendors.yaml
Normal 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
|
||||
|
|
@ -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."));
|
||||
|
|
|
|||
80
packages/api/src/routes/departments.ts
Normal file
80
packages/api/src/routes/departments.ts
Normal 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;
|
||||
}
|
||||
78
packages/api/src/routes/gl-accounts.ts
Normal file
78
packages/api/src/routes/gl-accounts.ts
Normal 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;
|
||||
}
|
||||
49
packages/api/src/routes/helpers.ts
Normal file
49
packages/api/src/routes/helpers.ts
Normal 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 };
|
||||
102
packages/api/src/routes/master-data.test.ts
Normal file
102
packages/api/src/routes/master-data.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
61
packages/api/src/routes/users.ts
Normal file
61
packages/api/src/routes/users.ts
Normal 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;
|
||||
}
|
||||
97
packages/api/src/routes/vendors.ts
Normal file
97
packages/api/src/routes/vendors.ts
Normal 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;
|
||||
}
|
||||
172
packages/api/src/test/fake-db.ts
Normal file
172
packages/api/src/test/fake-db.ts
Normal 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 };
|
||||
|
|
@ -3,5 +3,5 @@
|
|||
"compilerOptions": {
|
||||
"noEmit": false
|
||||
},
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"exclude": ["src/**/*.test.ts", "src/test/**"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] } };
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue