mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-10-03 23:33:13 +00:00
feat(api): add approval policy, inbox, and activity stubs
This commit is contained in:
parent
f08b538a19
commit
9ae54d4a2a
16 changed files with 1047 additions and 11 deletions
|
|
@ -352,3 +352,135 @@ Document:
|
|||
downloadUrl:
|
||||
type: string
|
||||
description: Presigned GET URL returned on confirm or get.
|
||||
ApprovalPolicy:
|
||||
type: object
|
||||
required: [id, name, priority, active, createdAt, updatedAt]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Policy primary key.
|
||||
example: cccccccc-cccc-4ccc-8ccc-cccccccccccc
|
||||
name:
|
||||
type: string
|
||||
description: Policy display name.
|
||||
example: Default approver policy
|
||||
priority:
|
||||
type: integer
|
||||
description: Lower numbers match first.
|
||||
example: 10
|
||||
amountThreshold:
|
||||
type: [string, "null"]
|
||||
description: Minimum invoice amount that matches this policy.
|
||||
example: "0.00"
|
||||
skipBelowAmount:
|
||||
type: [string, "null"]
|
||||
description: Amounts below this skip the approval step.
|
||||
example: "25.00"
|
||||
active:
|
||||
type: boolean
|
||||
description: Whether the policy is considered when matching.
|
||||
example: true
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Row creation time.
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Row update time.
|
||||
ApprovalStep:
|
||||
type: object
|
||||
required: [id, invoiceId, stepOrder, approverRole, status, createdAt]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Step primary key.
|
||||
example: eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee
|
||||
invoiceId:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Parent invoice id.
|
||||
policyId:
|
||||
type: [string, "null"]
|
||||
format: uuid
|
||||
description: Policy that created the step.
|
||||
stepOrder:
|
||||
type: integer
|
||||
description: Order within the invoice.
|
||||
example: 1
|
||||
approverRole:
|
||||
type: string
|
||||
enum: [admin, ap_processor, approver, viewer]
|
||||
description: Role allowed to act when no assignee is set.
|
||||
example: approver
|
||||
assigneeUserId:
|
||||
type: [string, "null"]
|
||||
format: uuid
|
||||
description: Optional assigned user.
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, approved, rejected, skipped]
|
||||
description: Step status.
|
||||
example: pending
|
||||
actedByUserId:
|
||||
type: [string, "null"]
|
||||
format: uuid
|
||||
description: User who acted.
|
||||
actedAt:
|
||||
type: [string, "null"]
|
||||
format: date-time
|
||||
description: When the step was acted on.
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Row creation time.
|
||||
InvoiceComment:
|
||||
type: object
|
||||
required: [id, invoiceId, body, createdAt]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Comment primary key.
|
||||
invoiceId:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Parent invoice id.
|
||||
authorUserId:
|
||||
type: [string, "null"]
|
||||
format: uuid
|
||||
description: Author user id.
|
||||
body:
|
||||
type: string
|
||||
description: Comment text.
|
||||
example: Looks good.
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Row creation time.
|
||||
ActivityLog:
|
||||
type: object
|
||||
required: [id, invoiceId, message, createdAt]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Activity row primary key.
|
||||
invoiceId:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Parent invoice id.
|
||||
actorUserId:
|
||||
type: [string, "null"]
|
||||
format: uuid
|
||||
description: Actor user id.
|
||||
message:
|
||||
type: string
|
||||
description: Activity message.
|
||||
example: Step approved.
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Row creation time.
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ tags:
|
|||
description: Invoice headers, coding lines, and uniqueness rules.
|
||||
- name: Documents
|
||||
description: Presigned document upload, confirm, and download against MinIO or S3.
|
||||
- name: Approvals
|
||||
description: Approval policies, step decisions, inbox, comments, and activity.
|
||||
paths:
|
||||
/api/health:
|
||||
$ref: ./paths/health.yaml
|
||||
|
|
@ -62,6 +64,18 @@ paths:
|
|||
$ref: ./paths/documents-id.yaml
|
||||
/api/documents/{id}/confirmations:
|
||||
$ref: ./paths/documents-id-confirmations.yaml
|
||||
/api/approval-policies:
|
||||
$ref: ./paths/approval-policies.yaml
|
||||
/api/approval-policies/{id}:
|
||||
$ref: ./paths/approval-policies-id.yaml
|
||||
/api/approval-steps/{id}/decisions:
|
||||
$ref: ./paths/approval-steps-id-decisions.yaml
|
||||
/api/inbox:
|
||||
$ref: ./paths/inbox.yaml
|
||||
/api/invoices/{id}/comments:
|
||||
$ref: ./paths/invoices-id-comments.yaml
|
||||
/api/invoices/{id}/activity-logs:
|
||||
$ref: ./paths/invoices-id-activity-logs.yaml
|
||||
components:
|
||||
securitySchemes:
|
||||
cookieAuth:
|
||||
|
|
@ -89,5 +103,13 @@ components:
|
|||
$ref: ./components/schemas.yaml#/InvoiceLine
|
||||
Document:
|
||||
$ref: ./components/schemas.yaml#/Document
|
||||
ApprovalPolicy:
|
||||
$ref: ./components/schemas.yaml#/ApprovalPolicy
|
||||
ApprovalStep:
|
||||
$ref: ./components/schemas.yaml#/ApprovalStep
|
||||
InvoiceComment:
|
||||
$ref: ./components/schemas.yaml#/InvoiceComment
|
||||
ActivityLog:
|
||||
$ref: ./components/schemas.yaml#/ActivityLog
|
||||
security:
|
||||
- cookieAuth: []
|
||||
|
|
|
|||
91
packages/api/openapi/paths/approval-policies-id.yaml
Normal file
91
packages/api/openapi/paths/approval-policies-id.yaml
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Policy primary key.
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
example: cccccccc-cccc-4ccc-8ccc-cccccccccccc
|
||||
get:
|
||||
tags: [Approvals]
|
||||
summary: Get an approval policy
|
||||
description: Returns one approval policy by id.
|
||||
operationId: get-api-approval-policies-id
|
||||
responses:
|
||||
"200":
|
||||
description: Policy.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/ApprovalPolicy
|
||||
"400":
|
||||
description: Invalid id.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
"401":
|
||||
description: Missing session.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
"404":
|
||||
description: Policy not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
patch:
|
||||
tags: [Approvals]
|
||||
summary: Update an approval policy
|
||||
description: Admin-only patch. Requires admin:settings.
|
||||
operationId: patch-api-approval-policies-id
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
example: Default approver policy
|
||||
priority:
|
||||
type: integer
|
||||
example: 10
|
||||
amountThreshold:
|
||||
type: string
|
||||
example: "0.00"
|
||||
skipBelowAmount:
|
||||
type: string
|
||||
example: "25.00"
|
||||
active:
|
||||
type: boolean
|
||||
example: true
|
||||
responses:
|
||||
"200":
|
||||
description: Updated policy.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/ApprovalPolicy
|
||||
"400":
|
||||
description: Validation failed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
"403":
|
||||
description: Caller lacks admin:settings.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
"404":
|
||||
description: Policy not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
71
packages/api/openapi/paths/approval-policies.yaml
Normal file
71
packages/api/openapi/paths/approval-policies.yaml
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
get:
|
||||
tags: [Approvals]
|
||||
summary: List approval policies
|
||||
description: Returns every approval policy.
|
||||
operationId: get-api-approval-policies
|
||||
responses:
|
||||
"200":
|
||||
description: Policy list.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: ../components/schemas.yaml#/ApprovalPolicy
|
||||
"401":
|
||||
description: Missing session.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
post:
|
||||
tags: [Approvals]
|
||||
summary: Create an approval policy
|
||||
description: Admin-only insert. Requires admin:settings.
|
||||
operationId: post-api-approval-policies
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
example: High dollar
|
||||
priority:
|
||||
type: integer
|
||||
example: 1
|
||||
amountThreshold:
|
||||
type: string
|
||||
example: "500.00"
|
||||
skipBelowAmount:
|
||||
type: string
|
||||
example: "25.00"
|
||||
active:
|
||||
type: boolean
|
||||
example: true
|
||||
responses:
|
||||
"201":
|
||||
description: Created policy.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/ApprovalPolicy
|
||||
"400":
|
||||
description: Validation failed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
"403":
|
||||
description: Caller lacks admin:settings.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
57
packages/api/openapi/paths/approval-steps-id-decisions.yaml
Normal file
57
packages/api/openapi/paths/approval-steps-id-decisions.yaml
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Approval step primary key.
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
example: eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee
|
||||
post:
|
||||
tags: [Approvals]
|
||||
summary: Record an approval decision
|
||||
description: Approves, rejects, or skips a pending step. Requires approve:invoices.
|
||||
operationId: post-api-approval-steps-id-decisions
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [action]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [approve, reject, skip]
|
||||
example: approve
|
||||
responses:
|
||||
"200":
|
||||
description: Updated step.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/ApprovalStep
|
||||
"400":
|
||||
description: Validation failed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
"403":
|
||||
description: Caller lacks approve:invoices.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
"404":
|
||||
description: Step not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
"409":
|
||||
description: Step is not pending.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
24
packages/api/openapi/paths/inbox.yaml
Normal file
24
packages/api/openapi/paths/inbox.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
get:
|
||||
tags: [Approvals]
|
||||
summary: List the caller approval inbox
|
||||
description: Returns pending steps visible to the caller.
|
||||
operationId: get-api-inbox
|
||||
responses:
|
||||
"200":
|
||||
description: Inbox items.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: ../components/schemas.yaml#/ApprovalStep
|
||||
"401":
|
||||
description: Missing session.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
45
packages/api/openapi/paths/invoices-id-activity-logs.yaml
Normal file
45
packages/api/openapi/paths/invoices-id-activity-logs.yaml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Invoice primary key.
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
example: 88888888-8888-4888-8888-888888888888
|
||||
get:
|
||||
tags: [Approvals]
|
||||
summary: List invoice activity
|
||||
description: Returns activity log rows for one invoice.
|
||||
operationId: get-api-invoices-id-activity-logs
|
||||
responses:
|
||||
"200":
|
||||
description: Activity list.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: ../components/schemas.yaml#/ActivityLog
|
||||
"400":
|
||||
description: Invalid id.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
"401":
|
||||
description: Missing session.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
"404":
|
||||
description: Invoice not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
80
packages/api/openapi/paths/invoices-id-comments.yaml
Normal file
80
packages/api/openapi/paths/invoices-id-comments.yaml
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Invoice primary key.
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
example: 88888888-8888-4888-8888-888888888888
|
||||
get:
|
||||
tags: [Approvals]
|
||||
summary: List invoice comments
|
||||
description: Returns comments on one invoice.
|
||||
operationId: get-api-invoices-id-comments
|
||||
responses:
|
||||
"200":
|
||||
description: Comment list.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: ../components/schemas.yaml#/InvoiceComment
|
||||
"400":
|
||||
description: Invalid id.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
"401":
|
||||
description: Missing session.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
"404":
|
||||
description: Invoice not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
post:
|
||||
tags: [Approvals]
|
||||
summary: Add an invoice comment
|
||||
description: Appends a comment and an activity row.
|
||||
operationId: post-api-invoices-id-comments
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [body]
|
||||
properties:
|
||||
body:
|
||||
type: string
|
||||
example: Looks good.
|
||||
responses:
|
||||
"201":
|
||||
description: Created comment.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/InvoiceComment
|
||||
"400":
|
||||
description: Validation failed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
"404":
|
||||
description: Invoice not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas.yaml#/Error
|
||||
|
|
@ -10,6 +10,7 @@ import { createGlAccountRoutes } from "./routes/gl-accounts.js";
|
|||
import { createDepartmentRoutes } from "./routes/departments.js";
|
||||
import { createUserRoutes } from "./routes/users.js";
|
||||
import { createInvoiceRoutes } from "./routes/invoices.js";
|
||||
import { createApprovalRoutes } from "./routes/approvals.js";
|
||||
import { createDocumentsStore, type DocumentsStore } from "./documents.js";
|
||||
import { errorJson } from "./http.js";
|
||||
import { cloudFrontOriginAllowed } from "./auth/origin-verify.js";
|
||||
|
|
@ -52,6 +53,7 @@ export function createApp(env: ApiEnv, handle: Db, deps: AppDeps = {}) {
|
|||
api.route("/", createDepartmentRoutes(handle));
|
||||
api.route("/", createUserRoutes(handle));
|
||||
api.route("/", createInvoiceRoutes(handle, deps.documents ?? createDocumentsStore(env)));
|
||||
api.route("/", createApprovalRoutes(handle));
|
||||
app.route("/api", api);
|
||||
|
||||
app.notFound((c) => errorJson(c, 404, "NOT_FOUND", "Not found."));
|
||||
|
|
|
|||
74
packages/api/src/approvals.ts
Normal file
74
packages/api/src/approvals.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import type { Db } from "./db/client.js";
|
||||
import { activityLog, approvalPolicies, approvalSteps, invoices } from "./db/schema/index.js";
|
||||
import { moneyCents, rowsOf } from "./routes/helpers.js";
|
||||
|
||||
type InvoiceRow = typeof invoices.$inferSelect;
|
||||
type PolicyRow = typeof approvalPolicies.$inferSelect;
|
||||
type StepRow = typeof approvalSteps.$inferSelect;
|
||||
|
||||
export async function appendActivity(
|
||||
handle: Db,
|
||||
invoiceId: string,
|
||||
actorUserId: string,
|
||||
message: string,
|
||||
) {
|
||||
await handle.db.insert(activityLog).values({ invoiceId, actorUserId, message }).returning();
|
||||
}
|
||||
|
||||
export async function applyMatchingPolicy(
|
||||
handle: Db,
|
||||
invoice: InvoiceRow,
|
||||
actorUserId: string,
|
||||
): Promise<InvoiceRow> {
|
||||
await appendActivity(handle, invoice.id, actorUserId, "Invoice created.");
|
||||
const policies = (await rowsOf<PolicyRow>(handle, approvalPolicies))
|
||||
.filter((policy) => policy.active)
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
const amount = moneyCents(invoice.amount);
|
||||
const matched = policies.find((policy) => {
|
||||
if (policy.amountThreshold == null) return true;
|
||||
return amount >= moneyCents(policy.amountThreshold);
|
||||
});
|
||||
if (!matched) return invoice;
|
||||
|
||||
const skip = matched.skipBelowAmount != null && amount < moneyCents(matched.skipBelowAmount);
|
||||
await handle.db
|
||||
.insert(approvalSteps)
|
||||
.values({
|
||||
invoiceId: invoice.id,
|
||||
policyId: matched.id,
|
||||
stepOrder: 1,
|
||||
approverRole: "approver",
|
||||
status: skip ? "skipped" : "pending",
|
||||
actedByUserId: skip ? actorUserId : null,
|
||||
actedAt: skip ? new Date() : null,
|
||||
})
|
||||
.returning();
|
||||
if (!skip) {
|
||||
await appendActivity(
|
||||
handle,
|
||||
invoice.id,
|
||||
actorUserId,
|
||||
`Approval required by policy ${matched.name}.`,
|
||||
);
|
||||
return invoice;
|
||||
}
|
||||
await appendActivity(
|
||||
handle,
|
||||
invoice.id,
|
||||
actorUserId,
|
||||
`Step skipped because amount is below ${matched.skipBelowAmount}.`,
|
||||
);
|
||||
const [updated] = await handle.db
|
||||
.update(invoices)
|
||||
.set({ id: invoice.id, status: "approved", updatedAt: new Date() })
|
||||
.where(eq(invoices.id, invoice.id))
|
||||
.returning();
|
||||
return updated ?? { ...invoice, status: "approved" };
|
||||
}
|
||||
|
||||
export async function remainingPending(handle: Db, invoiceId: string): Promise<StepRow[]> {
|
||||
const rows = await rowsOf<StepRow>(handle, approvalSteps);
|
||||
return rows.filter((row) => row.invoiceId === invoiceId && row.status === "pending");
|
||||
}
|
||||
110
packages/api/src/routes/approvals.test.ts
Normal file
110
packages/api/src/routes/approvals.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createApp } from "../app.js";
|
||||
import { createMemoryDocumentsStore } from "../documents.js";
|
||||
import { loadEnv } from "../env.js";
|
||||
import type { ErrorEnvelope } from "../http.js";
|
||||
import { createFakeDb, SEED } from "../test/fake-db.js";
|
||||
|
||||
function expectEnvelope(body: unknown, code: string) {
|
||||
const envelope = body as ErrorEnvelope;
|
||||
expect(envelope.error.code).toBe(code);
|
||||
}
|
||||
|
||||
function envFor(role: "admin" | "approver" | "viewer") {
|
||||
return loadEnv({
|
||||
NODE_ENV: "test",
|
||||
DEV_AUTH_BYPASS: "true",
|
||||
DEV_AUTH_SUB: SEED.user.cognitoSub,
|
||||
DEV_AUTH_EMAIL: SEED.user.email,
|
||||
DEV_AUTH_NAME: SEED.user.name,
|
||||
DEV_AUTH_ROLE: role,
|
||||
});
|
||||
}
|
||||
|
||||
const jsonHeaders = {
|
||||
"content-type": "application/json",
|
||||
origin: "http://127.0.0.1:3000",
|
||||
};
|
||||
|
||||
function app(role: "admin" | "approver" | "viewer" = "admin") {
|
||||
return createApp(envFor(role), createFakeDb(), { documents: createMemoryDocumentsStore() });
|
||||
}
|
||||
|
||||
describe("approval stubs", () => {
|
||||
it("skips a step when the invoice is below skipBelowAmount", async () => {
|
||||
const api = app();
|
||||
const response = await api.request("/api/invoices", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({
|
||||
vendorId: SEED.vendor.id,
|
||||
invoiceNumber: "INV-SKIP",
|
||||
amount: "10.00",
|
||||
dueDate: "2026-10-01",
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
const invoice = (await response.json()) as { id: string; status: string };
|
||||
expect(invoice.status).toBe("approved");
|
||||
const activity = await api.request(`/api/invoices/${invoice.id}/activity-logs`);
|
||||
expect(activity.status).toBe(200);
|
||||
const log = (await activity.json()) as { items: Array<{ message: string }> };
|
||||
expect(log.items.some((item) => item.message.includes("below"))).toBe(true);
|
||||
});
|
||||
|
||||
it("approves a pending step and writes activity", async () => {
|
||||
const api = app("approver");
|
||||
const response = await api.request(`/api/approval-steps/${SEED.step.id}/decisions`, {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ action: "approve" }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({ status: "approved" });
|
||||
const activity = await api.request(`/api/invoices/${SEED.invoice.id}/activity-logs`);
|
||||
const log = (await activity.json()) as { items: Array<{ message: string }> };
|
||||
expect(log.items.some((item) => item.message === "Step approved.")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 403 when a viewer acts on a step", async () => {
|
||||
const api = app("viewer");
|
||||
const response = await api.request(`/api/approval-steps/${SEED.step.id}/decisions`, {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ action: "approve" }),
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expectEnvelope(await response.json(), "FORBIDDEN");
|
||||
});
|
||||
|
||||
it("lists the caller inbox and accepts a comment", async () => {
|
||||
const api = app("admin");
|
||||
const inbox = await api.request("/api/inbox");
|
||||
expect(inbox.status).toBe(200);
|
||||
const listed = (await inbox.json()) as { items: Array<{ id: string }> };
|
||||
expect(listed.items[0]?.id).toBe(SEED.step.id);
|
||||
const comment = await api.request(`/api/invoices/${SEED.invoice.id}/comments`, {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ body: "Looks good." }),
|
||||
});
|
||||
expect(comment.status).toBe(201);
|
||||
await expect(comment.json()).resolves.toMatchObject({ body: "Looks good." });
|
||||
});
|
||||
|
||||
it("creates an approval policy as admin", async () => {
|
||||
const api = app("admin");
|
||||
const response = await api.request("/api/approval-policies", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({
|
||||
name: "High dollar",
|
||||
priority: 1,
|
||||
amountThreshold: "500.00",
|
||||
skipBelowAmount: "0.00",
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
await expect(response.json()).resolves.toMatchObject({ name: "High dollar", priority: 1 });
|
||||
});
|
||||
});
|
||||
285
packages/api/src/routes/approvals.ts
Normal file
285
packages/api/src/routes/approvals.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import type { Db } from "../db/client.js";
|
||||
import {
|
||||
activityLog,
|
||||
approvalPolicies,
|
||||
approvalSteps,
|
||||
invoiceComments,
|
||||
invoices,
|
||||
} from "../db/schema/index.js";
|
||||
import type { AppBindings } from "../auth/middleware.js";
|
||||
import { errorJson } from "../http.js";
|
||||
import {
|
||||
asMoney,
|
||||
asString,
|
||||
caller,
|
||||
firstById,
|
||||
iso,
|
||||
isUuid,
|
||||
parseJsonBody,
|
||||
requireCan,
|
||||
rowsOf,
|
||||
} from "./helpers.js";
|
||||
import { appendActivity, remainingPending } from "../approvals.js";
|
||||
import type { UserRole } from "../env.js";
|
||||
|
||||
type PolicyRow = typeof approvalPolicies.$inferSelect;
|
||||
type StepRow = typeof approvalSteps.$inferSelect;
|
||||
type InvoiceRow = typeof invoices.$inferSelect;
|
||||
type CommentRow = typeof invoiceComments.$inferSelect;
|
||||
type ActivityRow = typeof activityLog.$inferSelect;
|
||||
|
||||
const DECISIONS = ["approve", "reject", "skip"] as const;
|
||||
type Decision = (typeof DECISIONS)[number];
|
||||
|
||||
function isDecision(value: string): value is Decision {
|
||||
return (DECISIONS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function toPolicy(row: PolicyRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
priority: row.priority,
|
||||
amountThreshold: row.amountThreshold,
|
||||
skipBelowAmount: row.skipBelowAmount,
|
||||
active: row.active,
|
||||
createdAt: iso(row.createdAt),
|
||||
updatedAt: iso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function toStep(row: StepRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
invoiceId: row.invoiceId,
|
||||
policyId: row.policyId,
|
||||
stepOrder: row.stepOrder,
|
||||
approverRole: row.approverRole,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
status: row.status,
|
||||
actedByUserId: row.actedByUserId,
|
||||
actedAt: row.actedAt ? iso(row.actedAt) : null,
|
||||
createdAt: iso(row.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
function inboxVisible(step: StepRow, user: { id: string; role: UserRole }): boolean {
|
||||
if (step.status !== "pending") return false;
|
||||
if (step.assigneeUserId) return step.assigneeUserId === user.id;
|
||||
return user.role === "admin" || user.role === step.approverRole;
|
||||
}
|
||||
|
||||
export function createApprovalRoutes(handle: Db) {
|
||||
const routes = new Hono<AppBindings>();
|
||||
|
||||
routes.get("/approval-policies", async (c) => {
|
||||
const denied = requireCan(c, "read:invoices");
|
||||
if (denied) return denied;
|
||||
const rows = await rowsOf<PolicyRow>(handle, approvalPolicies);
|
||||
return c.json({ items: rows.map(toPolicy) });
|
||||
});
|
||||
|
||||
routes.post("/approval-policies", async (c) => {
|
||||
const denied = requireCan(c, "admin:settings");
|
||||
if (denied) return denied;
|
||||
const body = await parseJsonBody(c);
|
||||
const name = asString(body.name).trim();
|
||||
if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required.");
|
||||
const [row] = await handle.db
|
||||
.insert(approvalPolicies)
|
||||
.values({
|
||||
name,
|
||||
priority: typeof body.priority === "number" ? body.priority : 100,
|
||||
amountThreshold: asMoney(body.amountThreshold),
|
||||
skipBelowAmount: asMoney(body.skipBelowAmount),
|
||||
active: body.active === false ? false : true,
|
||||
})
|
||||
.returning();
|
||||
return c.json(toPolicy(row), 201);
|
||||
});
|
||||
|
||||
routes.get("/approval-policies/:id", async (c) => {
|
||||
const denied = requireCan(c, "read:invoices");
|
||||
if (denied) return denied;
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid policy id.");
|
||||
const row = await firstById<PolicyRow>(handle, approvalPolicies, id);
|
||||
if (!row) return errorJson(c, 404, "NOT_FOUND", "Policy not found.");
|
||||
return c.json(toPolicy(row));
|
||||
});
|
||||
|
||||
routes.patch("/approval-policies/:id", async (c) => {
|
||||
const denied = requireCan(c, "admin:settings");
|
||||
if (denied) return denied;
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid policy id.");
|
||||
const existing = await firstById<PolicyRow>(handle, approvalPolicies, id);
|
||||
if (!existing) return errorJson(c, 404, "NOT_FOUND", "Policy not found.");
|
||||
const body = await parseJsonBody(c);
|
||||
const patch: Partial<typeof approvalPolicies.$inferInsert> & { id: string } = {
|
||||
id,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (body.name !== undefined) {
|
||||
const name = asString(body.name).trim();
|
||||
if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required.");
|
||||
patch.name = name;
|
||||
}
|
||||
if (typeof body.priority === "number") patch.priority = body.priority;
|
||||
if (body.amountThreshold !== undefined) patch.amountThreshold = asMoney(body.amountThreshold);
|
||||
if (body.skipBelowAmount !== undefined) patch.skipBelowAmount = asMoney(body.skipBelowAmount);
|
||||
if (typeof body.active === "boolean") patch.active = body.active;
|
||||
const [row] = await handle.db
|
||||
.update(approvalPolicies)
|
||||
.set(patch)
|
||||
.where(eq(approvalPolicies.id, id))
|
||||
.returning();
|
||||
return c.json(toPolicy(row));
|
||||
});
|
||||
|
||||
routes.get("/inbox", async (c) => {
|
||||
const denied = requireCan(c, "read:invoices");
|
||||
if (denied) return denied;
|
||||
const user = caller(c);
|
||||
const steps = await rowsOf<StepRow>(handle, approvalSteps);
|
||||
const invoiceRows = await rowsOf<InvoiceRow>(handle, invoices);
|
||||
const items = steps
|
||||
.filter((step) => inboxVisible(step, user))
|
||||
.map((step) => {
|
||||
const invoice = invoiceRows.find((row) => row.id === step.invoiceId);
|
||||
return {
|
||||
...toStep(step),
|
||||
invoiceNumber: invoice?.invoiceNumber ?? null,
|
||||
amount: invoice?.amount ?? null,
|
||||
vendorId: invoice?.vendorId ?? null,
|
||||
};
|
||||
});
|
||||
return c.json({ items });
|
||||
});
|
||||
|
||||
routes.post("/approval-steps/:id/decisions", async (c) => {
|
||||
const denied = requireCan(c, "approve:invoices");
|
||||
if (denied) return denied;
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid step id.");
|
||||
const step = await firstById<StepRow>(handle, approvalSteps, id);
|
||||
if (!step) return errorJson(c, 404, "NOT_FOUND", "Approval step not found.");
|
||||
if (step.status !== "pending") {
|
||||
return errorJson(c, 409, "CONFLICT", "Step is not pending.");
|
||||
}
|
||||
const body = await parseJsonBody(c);
|
||||
const action = asString(body.action);
|
||||
if (!isDecision(action)) {
|
||||
return errorJson(c, 400, "VALIDATION_ERROR", "action must be approve, reject, or skip.");
|
||||
}
|
||||
const user = caller(c);
|
||||
const nextStatus =
|
||||
action === "approve" ? "approved" : action === "reject" ? "rejected" : "skipped";
|
||||
const [updated] = await handle.db
|
||||
.update(approvalSteps)
|
||||
.set({
|
||||
id,
|
||||
status: nextStatus,
|
||||
actedByUserId: user.id,
|
||||
actedAt: new Date(),
|
||||
})
|
||||
.where(eq(approvalSteps.id, id))
|
||||
.returning();
|
||||
await appendActivity(
|
||||
handle,
|
||||
step.invoiceId,
|
||||
user.id,
|
||||
action === "approve"
|
||||
? "Step approved."
|
||||
: action === "reject"
|
||||
? "Step rejected."
|
||||
: "Step skipped.",
|
||||
);
|
||||
let invoiceStatus: InvoiceRow["status"] | undefined;
|
||||
if (action === "reject") invoiceStatus = "rejected";
|
||||
else if ((await remainingPending(handle, step.invoiceId)).length === 0) {
|
||||
invoiceStatus = "approved";
|
||||
}
|
||||
if (invoiceStatus) {
|
||||
await handle.db
|
||||
.update(invoices)
|
||||
.set({ id: step.invoiceId, status: invoiceStatus, updatedAt: new Date() })
|
||||
.where(eq(invoices.id, step.invoiceId))
|
||||
.returning();
|
||||
}
|
||||
return c.json(toStep(updated));
|
||||
});
|
||||
|
||||
routes.get("/invoices/:id/comments", async (c) => {
|
||||
const denied = requireCan(c, "read:invoices");
|
||||
if (denied) return denied;
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id.");
|
||||
const invoice = await firstById<InvoiceRow>(handle, invoices, id);
|
||||
if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
|
||||
const rows = (await rowsOf<CommentRow>(handle, invoiceComments)).filter(
|
||||
(row) => row.invoiceId === id,
|
||||
);
|
||||
return c.json({
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
invoiceId: row.invoiceId,
|
||||
authorUserId: row.authorUserId,
|
||||
body: row.body,
|
||||
createdAt: iso(row.createdAt),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
routes.post("/invoices/:id/comments", async (c) => {
|
||||
const denied = requireCan(c, "read:invoices");
|
||||
if (denied) return denied;
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id.");
|
||||
const invoice = await firstById<InvoiceRow>(handle, invoices, id);
|
||||
if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
|
||||
const body = asString((await parseJsonBody(c)).body).trim();
|
||||
if (!body) return errorJson(c, 400, "VALIDATION_ERROR", "body is required.");
|
||||
const user = caller(c);
|
||||
const [row] = await handle.db
|
||||
.insert(invoiceComments)
|
||||
.values({ invoiceId: id, authorUserId: user.id, body })
|
||||
.returning();
|
||||
await appendActivity(handle, id, user.id, "Comment added.");
|
||||
return c.json(
|
||||
{
|
||||
id: row.id,
|
||||
invoiceId: row.invoiceId,
|
||||
authorUserId: row.authorUserId,
|
||||
body: row.body,
|
||||
createdAt: iso(row.createdAt),
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
|
||||
routes.get("/invoices/:id/activity-logs", async (c) => {
|
||||
const denied = requireCan(c, "read:invoices");
|
||||
if (denied) return denied;
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id.");
|
||||
const invoice = await firstById<InvoiceRow>(handle, invoices, id);
|
||||
if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
|
||||
const rows = (await rowsOf<ActivityRow>(handle, activityLog)).filter(
|
||||
(row) => row.invoiceId === id,
|
||||
);
|
||||
return c.json({
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
invoiceId: row.invoiceId,
|
||||
actorUserId: row.actorUserId,
|
||||
message: row.message,
|
||||
createdAt: iso(row.createdAt),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import type { DocumentsStore } from "../documents.js";
|
|||
import { documents, invoiceLines, invoices, vendors } from "../db/schema/index.js";
|
||||
import type { AppBindings } from "../auth/middleware.js";
|
||||
import { errorJson } from "../http.js";
|
||||
import { applyMatchingPolicy } from "../approvals.js";
|
||||
import {
|
||||
asMoney,
|
||||
asString,
|
||||
|
|
@ -246,7 +247,8 @@ export function createInvoiceRoutes(handle: Db, store: DocumentsStore) {
|
|||
.values({ invoiceId: row.id, ...line })
|
||||
.returning();
|
||||
}
|
||||
return c.json(toInvoice(row, await linesFor(handle, row.id)), 201);
|
||||
const latest = await applyMatchingPolicy(handle, row, caller(c).id);
|
||||
return c.json(toInvoice(latest, await linesFor(handle, row.id)), 201);
|
||||
});
|
||||
|
||||
routes.patch("/invoices/:id", async (c) => {
|
||||
|
|
@ -257,7 +259,7 @@ export function createInvoiceRoutes(handle: Db, store: DocumentsStore) {
|
|||
const existing = await firstById<InvoiceRow>(handle, invoices, id);
|
||||
if (!existing) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
|
||||
const body = await parseJsonBody(c);
|
||||
const patch: Partial<typeof invoices.$inferInsert> = { updatedAt: new Date() };
|
||||
const patch: Partial<typeof invoices.$inferInsert> = { id, updatedAt: new Date() };
|
||||
if (body.vendorId !== undefined) {
|
||||
const vendorId = asString(body.vendorId);
|
||||
if (!isUuid(vendorId)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid vendorId.");
|
||||
|
|
|
|||
|
|
@ -68,6 +68,42 @@ export const SEED = {
|
|||
uploadedByUserId: "11111111-1111-4111-8111-111111111111",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
},
|
||||
policy: {
|
||||
id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
name: "Default approver policy",
|
||||
priority: 10,
|
||||
amountThreshold: "0.00",
|
||||
skipBelowAmount: "25.00",
|
||||
active: true,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
},
|
||||
step: {
|
||||
id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
||||
invoiceId: "88888888-8888-4888-8888-888888888888",
|
||||
policyId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
stepOrder: 1,
|
||||
approverRole: "approver" as UserRole,
|
||||
assigneeUserId: null as string | null,
|
||||
status: "pending" as const,
|
||||
actedByUserId: null as string | null,
|
||||
actedAt: null as Date | null,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
},
|
||||
comment: {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
invoiceId: "88888888-8888-4888-8888-888888888888",
|
||||
authorUserId: "11111111-1111-4111-8111-111111111111",
|
||||
body: "Seed comment on INV-1001.",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
},
|
||||
activity: {
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
invoiceId: "88888888-8888-4888-8888-888888888888",
|
||||
actorUserId: "11111111-1111-4111-8111-111111111111",
|
||||
message: "Invoice created from seed.",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
},
|
||||
};
|
||||
|
||||
export type Store = {
|
||||
|
|
@ -78,10 +114,10 @@ export type Store = {
|
|||
invoices: Array<typeof SEED.invoice>;
|
||||
invoiceLines: Array<typeof SEED.line>;
|
||||
documents: Array<typeof SEED.document>;
|
||||
approvalPolicies: Array<Record<string, unknown>>;
|
||||
approvalSteps: Array<Record<string, unknown>>;
|
||||
invoiceComments: Array<Record<string, unknown>>;
|
||||
activityLog: Array<Record<string, unknown>>;
|
||||
approvalPolicies: Array<typeof SEED.policy>;
|
||||
approvalSteps: Array<typeof SEED.step>;
|
||||
invoiceComments: Array<typeof SEED.comment>;
|
||||
activityLog: Array<typeof SEED.activity>;
|
||||
};
|
||||
|
||||
export function emptyStore(): Store {
|
||||
|
|
@ -93,10 +129,10 @@ export function emptyStore(): Store {
|
|||
invoices: [{ ...SEED.invoice }],
|
||||
invoiceLines: [{ ...SEED.line }],
|
||||
documents: [{ ...SEED.document }],
|
||||
approvalPolicies: [],
|
||||
approvalSteps: [],
|
||||
invoiceComments: [],
|
||||
activityLog: [],
|
||||
approvalPolicies: [{ ...SEED.policy }],
|
||||
approvalSteps: [{ ...SEED.step }],
|
||||
invoiceComments: [{ ...SEED.comment }],
|
||||
activityLog: [{ ...SEED.activity }],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -182,7 +218,8 @@ export function createFakeDb(store: Store = emptyStore()): Db {
|
|||
where: vi.fn(() => ({
|
||||
returning: vi.fn(async () => {
|
||||
const rows = rowsFor(store, table);
|
||||
const current = rows[0];
|
||||
const current =
|
||||
(typeof patch.id === "string" && rows.find((row) => row.id === patch.id)) || rows[0];
|
||||
if (!current) return [];
|
||||
Object.assign(current, patch);
|
||||
return [current];
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ rules:
|
|||
- callback
|
||||
- refresh
|
||||
- logout
|
||||
- inbox
|
||||
paths-kebab-case: error
|
||||
no-invalid-schema-examples: error
|
||||
# No schema-properties casing rule: property names mirror the DynamoDB
|
||||
|
|
|
|||
|
|
@ -117,4 +117,7 @@ export type ApiPaths = {
|
|||
"/api/documents/{id}": {
|
||||
get: { response: Document };
|
||||
};
|
||||
"/api/inbox": {
|
||||
get: { response: { items: Array<{ id: string; invoiceId: string; status: string }> } };
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue