Author SHA1 Message Date
toanhoang a9dafcc140 fix: add atomic Assignment transfer (close old + create new in one transaction)
The only way to move an Assignment to a new employee was two separate calls
(PATCH return + POST create), which is not atomic and left a race window
where a third party could grab the now-in_stock AssetItem in between, plus
no rollback if the second call failed. Adds transferAssignment() which
closes the old Assignment and creates the new one inside a single
prisma.$transaction(), per docs/asset-license-management.md's "Assignment
(chốt 2026-08-02)" spec. Does not touch AssetItem.status or
License.seatsUsed — the resource never becomes free mid-transfer. Wired
into PATCH /api/assignments/[id]: an employeeId key in the body dispatches
to transfer, a returnedAt key to return, both together is rejected as
mutually exclusive.
2026-08-03 19:46:43 -04:00
toanhoang e951a14cc5 feat: add basic list+create+return UI for Assignment 2026-08-03 07:31:32 -04:00
toanhoang 2981ea6202 feat: add basic list+create UI for AssetItem, License, Request 2026-08-03 07:23:25 -04:00
toanhoang e471ae9c90 feat: add basic list+create UI for AssetCategoryAttributeDef, AssetModel, PurchaseOrder 2026-08-03 07:09:42 -04:00
toanhoang 4bcd4da11b feat: add basic list+create UI for Employee, Vendor, AssetCategory 2026-08-03 06:54:41 -04:00
toanhoang 0576d6e61c feat: add shared UI nav + fetch hook + table/select/label/textarea primitives
Basic infra for a functional (no-polish) UI pass over existing entity APIs.
2026-08-03 06:36:16 -04:00
toanhoang 6b39c01f4e test: add Assignment API tests
48 tests covering the XOR validation, the AssetItem.status/active-assignment
preconditions, seat overbooking, seat/status sync across assign+return,
FK-not-found for all three FKs, double-return, 404s, auth-gating, malformed
bodies, and two explicit concurrency tests (Promise.all against a
1-remaining-seat License and against a single in_stock AssetItem) exercising
the transaction race-closing logic in src/lib/assignments.ts.
2026-08-02 21:19:40 -04:00
toanhoang 5145c6af31 feat: add Assignment API routes
POST/GET /api/assignments mirror the existing entity route pattern
(auth-gated, thin handler delegating to src/lib/assignments.ts). PATCH
/api/assignments/[id] is the first dynamic-segment route in the codebase,
per the stakeholder-confirmed convention (docs/asset-license-management.md)
of update routes being PATCH /api/<entity>/[id] rather than an
action-based sub-route.
2026-08-02 21:19:34 -04:00
toanhoang a72b8dbbbe feat: add Assignment validation + CRUD (src/lib/assignments.ts)
createAssignment() enforces the XOR (assetItemId/licenseId) at the Zod
layer and the AssetItem.status/License.seatsUsed business rules inside a
single prisma.$transaction(), using atomic WHERE-guarded updateMany() calls
(not plain read-then-write) to close the seat-overbooking and
double-assignment race conditions flagged for License by code-reviewer.
returnAssignment() reverses those side-effects the same way and closes the
double-return race with the same pattern.
2026-08-02 21:19:28 -04:00
toanhoang 07b01cd707 feat: add Assignment Prisma model + migration
Links Employee to exactly one of AssetItem/License (XOR enforced at the
Zod layer, not built yet) via nullable FKs, plus assignedAt/returnedAt/
signedAt timestamps. See docs/asset-license-management.md's "Assignment"
section for the full spec.
2026-08-02 21:13:46 -04:00
toanhoangandClaude Sonnet 5 2037e6939e Merge branch 'feat/asset-item-model-id'
Retrofit AssetItem.model (string) -> modelId (FK to AssetModel) —
closes out Lane B (AssetCategory -> AssetCategoryAttributeDef +
AssetModel -> dynamic specs validation -> AssetItem.modelId).
Verified green by test-runner (225/225 tests, tsc/lint/build clean)
before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 21:11:13 -04:00
toanhoang 2a5792df67 fix: retrofit AssetItem.model to modelId FK
AssetItem.model was a free-text string with no referential integrity.
Now a required FK to AssetModel, mirroring the Request.employeeId and
License.vendorId retrofit precedent (no backfill needed, no real
AssetItem data existed).
2026-08-02 21:07:12 -04:00
toanhoangandClaude Sonnet 5 d280279322 Merge branch 'feat/asset-model-dynamic-specs'
Wire dynamic specs validation into AssetModel creation: builds a
runtime Zod schema from the category's AssetCategoryAttributeDef
rows, rejects unknown keys, enforces required keys. Verified green
by test-runner (223/223 tests, tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 21:02:21 -04:00
toanhoang dc4c438ca9 feat: wire dynamic specs validation into AssetModel creation 2026-08-02 20:58:19 -04:00
toanhoangandClaude Sonnet 5 bcd41e4ba3 Merge branch 'feat/asset-model'
AssetModel entity: Prisma model+migration, Zod+CRUD (categoryId FK
+ business rule requiring valueGroup=high_value, specs JSONB loosely
validated for now), API routes, tests. Verified green by test-runner
(217/217 tests, tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 20:54:06 -04:00
toanhoang 0fdf82be61 test: add AssetModel API tests 2026-08-02 20:49:24 -04:00
toanhoang c9b595118a feat: add AssetModel API routes 2026-08-02 20:48:09 -04:00
toanhoang 1c530a3162 feat: add AssetModel validation + CRUD (src/lib/asset-models.ts) 2026-08-02 20:48:09 -04:00
toanhoang c02235f381 feat: add AssetModel Prisma model + migration 2026-08-02 20:46:48 -04:00
toanhoangandClaude Sonnet 5 d3536b60ad Merge branch 'feat/asset-category-attribute-def'
AssetCategoryAttributeDef entity: Prisma model+migration (dataType
enum, composite unique (categoryId,key)), Zod+CRUD (P2002+P2003),
API routes, tests. Verified green by test-runner (192/192 tests,
tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 20:44:32 -04:00
toanhoang b91a1d2185 test: add AssetCategoryAttributeDef API tests
28 tests covering lib-level validation/CRUD and route-level HTTP behavior:
malformed JSON, non-object body, invalid dataType, categoryId FK violation,
duplicate (categoryId, key) rejected with a clean 400 (not 500), and same
key allowed across two different categories (the "not globally unique"
case), plus auth gating.
2026-08-02 20:40:17 -04:00
toanhoang 59425a159b feat: add AssetCategoryAttributeDef API routes
POST/GET /api/asset-category-attribute-defs, auth-gated, thin route mirroring
src/app/api/asset-categories/route.ts.
2026-08-02 20:38:46 -04:00
toanhoang 123cbce2bc feat: add AssetCategoryAttributeDef validation + CRUD (src/lib/asset-category-attribute-defs.ts)
Zod schema + validate function, Prisma-backed CRUD following the
{ok,value}|{ok,error} pattern from the start. createAssetCategoryAttributeDef()
catches P2002 for the composite (categoryId, key) unique constraint (mirrors
createAssetItem()'s serialNumber handling) and P2003 for the categoryId FK
(mirrors createPurchaseOrder()).
2026-08-02 20:38:41 -04:00
toanhoang f7b9566b04 feat: add AssetCategoryAttributeDef Prisma model + migration
Per-category dynamic attribute definitions (key/label/dataType/required/
sortOrder), FK to AssetCategory, composite unique (categoryId, key) so the
same key is allowed across different categories but not duplicated within
one. dataType is a new closed Prisma enum (AssetAttributeDataType), a
dev-defined technical set distinct from AssetCategoryValueGroup.
2026-08-02 20:38:06 -04:00
toanhoangandClaude Sonnet 5 065c8cb59e Merge branch 'feat/asset-category'
AssetCategory entity: Prisma model+migration (valueGroup enum
high_value/low_value), Zod+CRUD, API routes, tests. Verified green by
test-runner (164/164 tests, tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 20:36:20 -04:00
toanhoang aaab8fd9b7 test: add AssetCategory API tests 2026-08-02 20:32:38 -04:00
toanhoang 63eb3df33d feat: add AssetCategory API routes 2026-08-02 20:31:27 -04:00
toanhoang 20ec7a11df feat: add AssetCategory validation + CRUD (src/lib/asset-categories.ts) 2026-08-02 20:31:22 -04:00
toanhoang 69fa00012b feat: add AssetCategory Prisma model + migration 2026-08-02 20:30:38 -04:00
toanhoangandClaude Sonnet 5 b8b1709a31 Merge branch 'feat/request-po-id'
Add nullable Request.purchaseOrderId FK (demand-side PO
traceability) — closes out Lane A (Vendor -> License.vendorId ->
PurchaseOrder -> purchaseOrderId x3). Verified green by test-runner
(143/143 tests, tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 20:28:50 -04:00
toanhoang 65232fc62c feat: add Request.purchaseOrderId (nullable FK) 2026-08-02 20:24:59 -04:00
toanhoangandClaude Sonnet 5 3dd1b22828 Merge branch 'feat/license-po-id'
Add nullable License.purchaseOrderId FK (supply-side PO
traceability). Verified green by test-runner (139/139 tests,
tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 20:20:58 -04:00
toanhoang abec58d239 feat: add License.purchaseOrderId (nullable FK)
Nullable FK from License to PurchaseOrder for supply-side traceability
(which PO a license came in through), mirroring AssetItem.purchaseOrderId's
just-merged pattern exactly. Separate commit from the earlier
License.vendorId retrofit per the project's 1-commit-per-business-change
convention.
2026-08-02 20:17:09 -04:00
toanhoangandClaude Sonnet 5 fb52f473a5 Merge branch 'feat/asset-item-po-id'
Add nullable AssetItem.purchaseOrderId FK (supply-side PO
traceability). Verified green by test-runner (131/131 tests,
tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 20:12:50 -04:00
toanhoang a6d7c7b302 feat: add AssetItem.purchaseOrderId (nullable FK) 2026-08-02 20:09:06 -04:00
toanhoangandClaude Sonnet 5 4d12885500 Merge branch 'feat/purchase-order'
PurchaseOrder entity: Prisma model+migration, Zod+CRUD (P2003 on both
vendorId/receivedById, receivedAt/receivedById co-nullability
refine), API routes, tests. Verified green by test-runner (127/127
tests, tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 20:04:58 -04:00
toanhoang 2b3c39a291 test: add PurchaseOrder API tests 2026-08-02 20:00:48 -04:00
toanhoang ad4b5d7c2f feat: add PurchaseOrder API routes 2026-08-02 19:59:18 -04:00
toanhoang cc98ca4d51 feat: add PurchaseOrder validation + CRUD (src/lib/purchase-orders.ts) 2026-08-02 19:59:05 -04:00
toanhoang 58235112f0 feat: add PurchaseOrder Prisma model + migration 2026-08-02 19:57:58 -04:00
toanhoangandClaude Sonnet 5 7b5ab70596 Merge branch 'feat/license-vendor-id'
Retrofit License.vendor (string) -> vendorId (FK to Vendor),
createLicense() now returns {ok,error} pattern. Verified green by
test-runner (97/97 tests, tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 19:55:35 -04:00
toanhoang 9d99ebb425 fix: retrofit License.vendor to vendorId FK + createLicense error handling
License.vendor (free-text string) is now vendorId, a required FK to
Vendor, per docs/asset-license-management.md's "## Vendor (chốt
2026-08-02)" spec. Mirrors the Request.employeeId string->FK precedent:
Zod validates UUID format only, Prisma's own FK constraint (P2003) is
caught at insert time and translated into a clean 400 instead of a raw
500. No License data existed yet, so this is a pure column-type
migration, no backfill needed.

Also converts createLicense() to the {ok,value}|{ok,error} result
pattern used elsewhere (createRequest, createAssetItem) instead of
returning a bare LicenseRecord promise - closes the gap flagged in
CLAUDE.md now that License has its first FK-violation error case to
handle.
2026-08-02 19:41:33 -04:00
toanhoangandClaude Sonnet 5 9a4a3fede3 Merge branch 'feat/vendor'
Vendor entity: Prisma model+migration, Zod+CRUD, API routes, tests.
Verified green by test-runner (94/94 tests, tsc/lint/build clean)
before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 19:36:49 -04:00
toanhoang f613763ba4 test: add Vendor API tests 2026-08-02 19:29:48 -04:00
toanhoang 283d94f07a feat: add Vendor API routes 2026-08-02 19:28:36 -04:00
toanhoang 19b652142a feat: add Vendor validation + CRUD (src/lib/vendors.ts) 2026-08-02 19:28:25 -04:00
toanhoang 07ef1582d3 feat: add Vendor Prisma model + migration 2026-08-02 19:28:09 -04:00
toanhoangandClaude Sonnet 5 f325abf22c docs: resolve Assignment open questions, unblock Lane C
Stakeholder decided: createAssignment() blocks if AssetItem.status
isn't in_stock, and update routes use PATCH /api/<entity>/[id]
(applies to both Assignment and PurchaseOrder).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 19:22:43 -04:00
toanhoang e3a3780253 Merge branch 'worktree-vendor-po-assignment-spec'
# Conflicts:
#	docs/asset-license-management.md
2026-08-02 19:20:34 -04:00
toanhoangandClaude Sonnet 5 4a0d00afd8 docs: spec Vendor/PurchaseOrder/Assignment + attribute defs, sequence tasks
BA formalizes the Vendor, PurchaseOrder (absorbs Receipt), Assignment,
and AssetCategoryAttributeDef designs decided in stakeholder brainstorm
into docs/asset-license-management.md. PM sequences the resulting work
into dependency-ordered, commit-mapped tasks across 3 independent lanes
in CLAUDE.md, surfacing that AssetCategory/AssetModel/ConsumableItem
were spec'd earlier but never actually built, and flagging two open
questions (Assignment precondition on AssetItem.status; REST shape for
update operations) that need stakeholder input before Lane C starts.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 13:12:27 -04:00
toanhoangandClaude Sonnet 5 29febf8205 docs: add asset high-value/low-value classification spec
AssetCategory/AssetModel hierarchy, ConsumableItem for low-value/
consumable items, data constraints, and open questions for the
consumable request flow — decisions already confirmed with
stakeholder, spec was written but not yet committed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 13:01:02 -04:00
toanhoangandClaude Sonnet 5 513e70ff52 docs: add asset high-value/low-value classification spec
AssetCategory/AssetModel hierarchy, ConsumableItem for low-value/
consumable items, data constraints, and open questions for the
consumable request flow — decisions already confirmed with
stakeholder, spec was written but not yet committed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 13:00:51 -04:00
59 changed files with 9643 additions and 161 deletions
+7 -2
View File
@@ -113,10 +113,15 @@ Với mỗi mục tiêu nghiệp vụ stakeholder đưa ra (vd "thêm tracking s
- Rà lại advisory riêng của `next` (prod dependency, có vài high/moderate — vd SSRF qua WebSocket upgrade, middleware bypass) khi có dịp, tách biệt khỏi đợt retrofit Prisma/Zod này. - Rà lại advisory riêng của `next` (prod dependency, có vài high/moderate — vd SSRF qua WebSocket upgrade, middleware bypass) khi có dịp, tách biệt khỏi đợt retrofit Prisma/Zod này.
- Test coverage gaps (chưa block gì, chỉ là follow-up): chưa test malformed JSON body, chưa test JSON body không phải object (array/string). - Test coverage gaps (chưa block gì, chỉ là follow-up): chưa test malformed JSON body, chưa test JSON body không phải object (array/string).
- `Button` (shadcn) không `forwardRef`, React đang pin 18 — dormant risk, xem note ở "Trạng thái hiện tại". - `Button` (shadcn) không `forwardRef`, React đang pin 18 — dormant risk, xem note ở "Trạng thái hiện tại".
- Các entity còn lại: `PurchaseOrder`, `Receipt`, `Assignment` (`AssetItem` xong 2026-07-31, `License` xong 2026-08-02, xem "Trạng thái hiện tại"). `Assignment` là entity tiếp theo nên làm — nối `Employee`+`AssetItem`+`License`, trả lời trực tiếp câu hỏi nghiệp vụ "ai đang giữ máy/license nào" nêu ở `docs/asset-license-management.md`. - **`Vendor`/`PurchaseOrder`(gộp `Receipt`)/`Assignment`/`AssetCategoryAttributeDef` (2026-08-02): spec đầy đủ ở `docs/asset-license-management.md`, đã có task list phụ thuộc-đúng do `pm`-pass sequencing (2026-08-02), sẵn sàng giao `feature-implementer` theo đúng thứ tự dưới đây (3 lane độc lập, có thể xen kẽ):**
- **Lane A (Vendor → License.vendorId → PurchaseOrder → purchaseOrderId x3)**: (1-4) `Vendor` entity chuẩn 4 commit (model+migration/lib/API/test), không phụ thuộc gì. (5) Retrofit `License.vendor` (string) → `License.vendorId` (FK) — **1 commit gộp** (schema+migration+lib+test), theo đúng tiền lệ retrofit `Request.employeeId` (commit `49d96b7`, không tách lớp như entity mới vì tách ra sẽ để lại state nửa vời không chạy được) — tiện thể fix luôn finding cũ của `code-reviewer`: `createLicense()` chưa theo pattern `{ok,value}|{ok,error}`, giờ **bắt buộc** phải thêm vì `vendorId` là FK mới (cần bắt `P2003`). Phụ thuộc (1). (6-9) `PurchaseOrder` entity chuẩn 4 commit (`vendorId` FK, `receivedBy` FK→`Employee`, Zod `.refine()` cho ràng buộc `receivedAt`/`receivedBy` cùng null hoặc cùng có giá trị, bắt `P2003` cho cả 2 FK, theo đúng pattern `{ok,error}` ngay từ đầu) — phụ thuộc (1) `Vendor` + `Employee` (đã có). (10) `AssetItem.purchaseOrderId` — 1 commit gộp (nullable FK). (11) `License.purchaseOrderId` — 1 commit gộp **riêng** với task (5) dù cùng file, vì trigger phụ thuộc khác nhau (`Vendor` vs `PurchaseOrder`) — gộp chung sẽ vi phạm nguyên tắc 1-commit-1-thay-đổi-nghiệp-vụ. (12) `Request.purchaseOrderId` — 1 commit gộp. (10)(11)(12) đều chỉ phụ thuộc (6), độc lập lẫn nhau, thứ tự giữa 3 task này không quan trọng.
- **Lane B (AssetCategory → AssetCategoryAttributeDef + AssetModel → wire dynamic validation → AssetItem.modelId)**: **gap phát hiện khi sequencing** — `AssetCategory`/`AssetModel`/`ConsumableItem` đã có spec từ round BA trước (2026-08-02, xem "Phân loại tài sản" trong doc) nhưng **CHƯA build** (không có trong `prisma/schema.prisma` hiện tại, `AssetItem.model` vẫn là string tự do) — phải làm trước thì `AssetCategoryAttributeDef` mới có ý nghĩa. (13-16) `AssetCategory` entity chuẩn 4 commit (`name`, `valueGroup` enum `high_value`/`low_value`), không phụ thuộc gì. (17-20) `AssetCategoryAttributeDef` entity chuẩn 4 commit (`categoryId` FK, `key`, `label`, `dataType` enum, `required`, `sortOrder`, unique `(categoryId,key)` → bắt `P2002`) — phụ thuộc (13), độc lập với `AssetModel`. (21-24) `AssetModel` entity chuẩn 4 commit (`categoryId` FK + business rule "category phải `valueGroup=high_value`" — cần lookup DB, không phải Zod thuần; `manufacturer`, `name`, `specs` JSONB — ở task này `specs` chỉ validate lỏng là object hợp lệ, CHƯA khớp động theo `AssetCategoryAttributeDef`) — phụ thuộc (13), độc lập với (17-20). (25) Wire dynamic specs validation vào `AssetModel` create/update — **1 commit riêng**, không gộp vào (22), vì đây là logic mới/rủi ro (build Zod schema runtime từ `AssetCategoryAttributeDef`, map `dataType`→`z.string()/number()/boolean()/coerce.date()`, từ chối key lạ, bắt buộc key `required`) — phụ thuộc (18) và (22). (26) Retrofit `AssetItem.model` (string) → `AssetItem.modelId` (FK) — 1 commit gộp, phụ thuộc (21) (không cần đợi (25)).
- **Lane C (`Assignment`, độc lập hoàn toàn với Lane A/B — chỉ cần `Employee`+`AssetItem`+`License` đã có sẵn)**: (27) model+migration (`employeeId` FK, `assetItemId`/`licenseId` nullable FK, `assignedAt`, `returnedAt` nullable, `signedAt` nullable). (28) lib validation+CRUD — rủi ro nhất trong cả 4 entity: Zod `.refine()` XOR `assetItemId`/`licenseId`, chặn >1 `Assignment` active/`AssetItem`, transaction tăng/giảm `License.seatsUsed` kèm chặn overbook (`seatsUsed >= seatsTotal`), đồng bộ `AssetItem.status` (assigned/in_stock), helper transfer (đóng cũ+mở mới cùng 1 transaction). (29) API routes — **cần quyết định shape mới**: đây là entity đầu tiên cần thao tác "update" (return/transfer) trong khi codebase hiện tại **mọi entity đều chỉ có POST(create)+GET(list), chưa có route update nào** — đề xuất mặc định `POST /api/assignments` (assign/transfer) + `PATCH /api/assignments/[id]` (return, nhận `{returnedAt}}`), cần xác nhận lại khi giao task cho `pm`/`feature-implementer` vì không có tiền lệ. (30) tests. **(31) Bắt buộc code-reviewer pass riêng cho Assignment (diff 27-30) trước khi coi là xong** — đúng rule ở "Multi-agent dev workflow" (state-mutation-với-invariant, seat-count) — không phải 1 commit, là 1 gate.
- **2 câu hỏi mở đã được stakeholder trả lời (2026-08-02, xem "Assignment" trong `docs/asset-license-management.md`) — Lane C hết bị chặn**: (a) `createAssignment()` **chặn** nếu `AssetItem.status` khác `in_stock` (400). (b) Route update dùng `PATCH /api/<entity>/[id]` (áp dụng cho cả `Assignment` và `PurchaseOrder`), không dùng action-based sub-route.
- `ConsumableItem` (phần catalog, KHÔNG phải luồng "xin+nhận" — luồng đó vẫn BỊ CHẶN chờ 7 câu hỏi mở, xem doc): độc lập với toàn bộ 3 lane trên (chỉ cần `AssetCategory`), sẵn sàng cho `pm` chia task riêng nếu stakeholder muốn gộp vào cùng đợt — không đưa vào sequencing này vì nằm ngoài phạm vi 4 việc được giao lần này.
- Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled - Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled
- UI (hiện chỉ có placeholder home page) - UI (hiện chỉ có placeholder home page)
- Auth/phân quyền (ai được approve request, ai được xác nhận nhận hàng/bàn giao) - Auth/phân quyền (ai được approve request, ai được xác nhận nhận hàng/bàn giao)
--- ---
Cập nhật lần cuối: 2026-08-02 (deploy lên homelab ubi.toanhoang.work, License entity) Cập nhật lần cuối: 2026-08-02 (deploy lên homelab ubi.toanhoang.work, License entity, task sequencing cho Vendor/PurchaseOrder/Assignment/AssetCategoryAttributeDef)
+86 -9
View File
@@ -23,14 +23,17 @@ Theo dõi toàn bộ vòng đời tài sản/license: **Request → Approval →
| Entity | Ý nghĩa | | Entity | Ý nghĩa |
|---|---| |---|---|
| `Employee` | id, tên, phòng ban, manager | | `Employee` | id, tên, phòng ban, manager |
| `Vendor` | nhà cung cấp | | `Vendor` | id, `name`, `contactEmail`, `contactPhone`, `address`, `taxCode` (nullable — mã số thuế VN), `notes` (nullable) — nhà cung cấp/reseller mua hàng qua, **khác** `AssetModel.manufacturer` (hãng sản xuất/OEM). Xem "Vendor" bên dưới |
| `AssetItem` | hardware — serial number, model, ngày mua, bảo hành, trạng thái (in stock / assigned / repair / retired) | | `AssetItem` | hardware **high-value** (serial hóa bắt buộc) — serial number, model (FK → `AssetModel`, bắt buộc — không còn string tự do), ngày mua, bảo hành, trạng thái (in stock / assigned / repair / retired), `purchaseOrderId` (FK → `PurchaseOrder`, nullable — chiều cung, xem "PurchaseOrder" bên dưới). Category không còn gán trực tiếp trên `AssetItem` — suy ra qua `AssetModel.categoryId`. Xem "Phân loại tài sản" bên dưới |
| `License` | phần mềm — tên, vendor, tổng seat, seat đã dùng, ngày hết hạn, loại (perpetual/subscription) | | `License` | phần mềm — tên, `vendorId` (FK → `Vendor`, retrofit từ string tự do — xem "Vendor" bên dưới), tổng seat, seat đã dùng, ngày hết hạn, loại (perpetual/subscription), `purchaseOrderId` (FK → `PurchaseOrder`, nullable — chiều cung, xem "PurchaseOrder" bên dưới) |
| `Request` | người yêu cầu, loại (asset/license), lý do, trạng thái | | `Request` | người yêu cầu, loại (asset/license), lý do, trạng thái, `purchaseOrderId` (FK → `PurchaseOrder`, nullable — chiều cầu, xem "PurchaseOrder" bên dưới) — **chỉ áp dụng cho asset high-value/license**, không dùng cho consumable (xem "Phân loại tài sản" bên dưới) |
| `PurchaseOrder` | gộp nhiều Request đã duyệt, vendor, ngày đặt, ngày dự kiến nhận | | `PurchaseOrder` | id, `vendorId` (FK → `Vendor`), `orderDate`, `expectedDate`, `receivedAt` (nullable — null nghĩa là chưa nhận), `receivedBy` (FK → `Employee`, nullable, set cùng lúc với `receivedAt`). **Gộp luôn `Receipt`** (không còn là entity riêng — nhận hàng là 1 phần trạng thái của chính PO), all-or-nothing theo cả PO (không nhận từng phần), không track giá/chi phí, không có `status` riêng (suy ra từ Request liên kết). Xem "PurchaseOrder" bên dưới |
| `Receipt` | nhận hàng — đối chiếu PO, ngày nhận, người nhận | | `Assignment` | id, `employeeId` (FK → `Employee`), `assetItemId` (FK → `AssetItem`, nullable) / `licenseId` (FK → `License`, nullable — đúng 1 trong 2, XOR), `assignedAt`, `returnedAt` (nullable — null nghĩa đang giữ), `signedAt` (nullable — chỉ là timestamp đánh dấu đã ký, không upload file). Gán AssetItem (high-value)/License cho 1 Employee cụ thể. **Không áp dụng cho consumable** (consumable track theo tồn kho, không gắn 1 người cụ thể). Xem "Assignment" bên dưới |
| `Assignment` | gán AssetItem/License cho Employee — ngày giao, ngày trả, biên bản ký nhận |
| `Request.servicenow_ticket` | field text, nhập tay số ticket ServiceNow liên quan (không tích hợp API ServiceNow ngay — volume thấp, nhập tay đủ dùng; để ngỏ cho auto-sync sau nếu cần) | | `Request.servicenow_ticket` | field text, nhập tay số ticket ServiceNow liên quan (không tích hợp API ServiceNow ngay — volume thấp, nhập tay đủ dùng; để ngỏ cho auto-sync sau nếu cần) |
| `AssetCategory` | id, tên (vd "Laptop", "Cáp HDMI"), nhóm giá trị cố định (`high_value` \| `low_value`) — danh mục category cụ thể do IT admin tự định nghĩa, không hardcode. Xem "Phân loại tài sản" bên dưới |
| `AssetModel` | id, category (FK → `AssetCategory`, bắt buộc, phải thuộc nhóm `high_value`), `manufacturer` (vd "Dell"), `name` (vd "Latitude 5420"), `specs` (JSONB — giá trị thật của các thuộc tính động, xem `AssetCategoryAttributeDef`) — tách riêng 2 field `manufacturer`/`name`, không gộp chung 1 string, để filter/report theo hãng. Nằm giữa `AssetCategory` và `AssetItem` (1 model có nhiều `AssetItem` serial khác nhau). Xem "Phân loại tài sản" bên dưới |
| `AssetCategoryAttributeDef` | id, `categoryId` (FK → `AssetCategory`), `key` (slug, vd `ram_gb`), `label` (tên hiển thị, vd "RAM (GB)"), `dataType` (enum đóng: `string`/`number`/`boolean`/`date`), `required` (bool), `sortOrder` (int) — định nghĩa thuộc tính riêng theo từng category, giá trị thật nằm ở `AssetModel.specs`. Unique `(categoryId, key)`. Xem "Phân loại tài sản" bên dưới |
| `ConsumableItem` | phụ kiện **low-value/consumable** — tên, category (bắt buộc, phải thuộc nhóm `low_value`, trỏ thẳng `AssetCategory` — **không** qua `AssetModel`, khái niệm "model" chỉ áp dụng cho asset serial hóa), số lượng tồn kho (`quantityOnHand`). **KHÔNG serial hóa, KHÔNG gắn Assignment cá nhân** — chỉ track theo số lượng. Xem "Phân loại tài sản" bên dưới |
## Luồng trạng thái Request ## Luồng trạng thái Request
@@ -40,6 +43,79 @@ Draft → Pending Approval → Approved → Ordered → Received → Delivered
Rejected / Cancelled Rejected / Cancelled
``` ```
## Phân loại tài sản: High-value vs Low-value/Consumable (chốt 2026-08-02)
**Quyết định đã chốt với stakeholder:** tài sản được phân nhóm theo **category cố định**, KHÔNG theo ngưỡng giá tiền. Mỗi `AssetCategory` thuộc đúng 1 trong 2 nhóm giá trị (`high_value` hoặc `low_value`), category cụ thể do IT admin tự định nghĩa/mở rộng — danh sách dưới đây chỉ là **ví dụ minh hoạ, không phải danh sách đóng**:
- Nhóm `high_value` → đi qua luồng `AssetItem` hiện có nguyên vẹn (serial hóa bắt buộc, track cá nhân qua `Assignment`, đi qua state machine đầy đủ của `Request`). Ví dụ category: Laptop, Màn hình, Máy in, Điện thoại công ty.
- Nhóm `low_value` (consumable) → entity mới `ConsumableItem`, **không serial hóa**, chỉ track theo số lượng tồn kho (`quantityOnHand`), **không gắn `Assignment`** cho 1 người cụ thể. Ví dụ category: Cáp (HDMI/USB-C/LAN), Chuột, Bàn phím, Túi đựng laptop, Sạc dự phòng.
**Cập nhật 2026-08-02 (2): thêm phân cấp `AssetModel` giữa `AssetCategory` và `AssetItem`.** Lý do: nhiều model khác nhau trong cùng 1 category (vd category "Laptop" có nhiều model "Dell Latitude 5420", "MacBook Pro 14"...), cần report/thống kê theo model (vd "đang có bao nhiêu Dell Latitude 5420"), tránh lỗi gõ tay khi model là string tự do như trước. Phân cấp: `AssetCategory` ("Laptop") → `AssetModel` ("Dell Latitude 5420") → từng `AssetItem` (1 serial cụ thể thuộc model đó). `AssetModel.manufacturer`/`AssetModel.name` tách riêng (không gộp 1 string) để filter/report riêng theo hãng. Khái niệm "model" chỉ có ý nghĩa cho asset serial hóa (`high_value`) — `ConsumableItem` không có model, vẫn trỏ thẳng `AssetCategory` (`low_value`) như cũ.
**Cập nhật 2026-08-02 (3): thêm `AssetCategoryAttributeDef` — thuộc tính đặc thù theo từng category.** Lý do: từng category có bộ thuộc tính riêng cần track (vd RAM/CPU cho category "Laptop", độ phân giải cho "Màn hình") mà không phải asset high-value nào cũng giống nhau — cần định nghĩa động theo category, không hardcode cột cứng cho từng loại thuộc tính.
- Entity mới `AssetCategoryAttributeDef`: `categoryId` (FK → `AssetCategory`), `key` (slug, vd `ram_gb`), `label` (tên hiển thị, vd "RAM (GB)"), `dataType` (enum đóng: `string`/`number`/`boolean`/`date` — đây là tập giá trị kỹ thuật cố định do dev định nghĩa, **khác** với `AssetCategory` là tập mở IT admin tự mở rộng), `required` (bool), `sortOrder` (int, thứ tự hiển thị trên form). Unique `(categoryId, key)`.
- Mỗi `AssetCategory` có bộ attribute **riêng, độc lập** — cố tình **không** phải thư viện thuộc tính dùng chung giữa các category. Nếu 2 category cùng muốn 1 thuộc tính kiểu "khối lượng (kg)", mỗi category tự tạo 1 row `AssetCategoryAttributeDef` riêng — trùng tên `key` giữa 2 category là bình thường và được chấp nhận, đây là 2 row không liên quan tới nhau. Lý do: ở quy mô hiện tại (ít category), thiết kế thư viện thuộc tính dùng chung (tách riêng bảng `AttributeDef` + bảng join `CategoryAttribute`) cộng thêm độ phức tạp thật (thêm 1 bảng, UX phải chọn "gắn thuộc tính có sẵn hay tạo mới", sửa 1 def ảnh hưởng dây chuyền nhiều category) để đổi lấy lợi ích (sửa 1 chỗ áp dụng khắp nơi, query cross-category an toàn) mà hiện chưa cần tới — stakeholder đã từ chối rõ ràng thiết kế dùng chung để giữ đơn giản.
- Giá trị thật nằm ở `AssetModel.specs` (cột JSONB) — **không** nằm trên `AssetItem`, vì đây là thuộc tính theo spec-sheet dùng chung cho mọi serial thuộc cùng 1 model (mọi `AssetItem` "Dell Latitude 5420" đều cùng RAM), không phải dữ liệu riêng theo từng instance. Validate động lúc ghi: app build Zod schema runtime từ các `AssetCategoryAttributeDef` thuộc category của model đó (map `dataType` → `z.string()`/`z.number()`/`z.boolean()`/`z.coerce.date()`), từ chối key lạ chưa được định nghĩa cho category, bắt buộc các key có `required = true` phải có mặt.
- Known gap chấp nhận được, chưa chặn: nếu admin sửa/xoá 1 `AssetCategoryAttributeDef` sau khi đã có `AssetModel` mang giá trị dưới key đó trong `specs`, giá trị cũ trở thành orphaned/stale — chưa có cơ chế migration/cleanup. Ghi nhận là known gap để xử lý khi có UI quản lý attribute-def thật, không cần giải quyết ngay bây giờ.
**Ràng buộc dữ liệu:**
- `AssetModel.categoryId` bắt buộc, phải trỏ tới 1 `AssetCategory` có `valueGroup = high_value`.
- `AssetItem.modelId` bắt buộc, phải trỏ tới 1 `AssetModel` có thật (thay cho field `model` string tự do trước đây — đã đổi, không cần chiến lược backfill vì DB chưa có `AssetItem` thật). `AssetItem` không còn giữ `categoryId` trực tiếp — category được suy ra qua `AssetItem.modelId → AssetModel.categoryId` (tránh 2 nguồn sự thật có thể lệch nhau).
- `ConsumableItem.categoryId` bắt buộc, phải trỏ tới 1 `AssetCategory` có `valueGroup = low_value` — trỏ thẳng, không qua `AssetModel`.
- `Request` (state machine đầy đủ) tiếp tục chỉ áp dụng cho `type: asset|license` như hiện tại — **không** mở rộng `RequestType` thêm `consumable`.
- `AssetCategoryAttributeDef.categoryId` bắt buộc, phải trỏ tới 1 `AssetCategory` có thật. Unique `(categoryId, key)`.
- `AssetModel.specs` (JSONB) chỉ được chứa các key đã có `AssetCategoryAttributeDef` tương ứng với category của model đó — validate ở tầng Zod (build động lúc runtime), không phải DB CHECK constraint (nhất quán với nguyên tắc "không raw SQL cho app logic" của codebase).
**Luồng riêng cho consumable ("xin + nhận" đơn giản hoá):** stakeholder đã xác nhận muốn 1 luồng gọn hơn state machine đầy đủ của `Request`, nhưng **chưa chốt chi tiết state/approval/xử lý hết hàng** — xem danh sách câu hỏi mở trong spec BA (2026-08-02, lưu ở lịch sử trao đổi/PR liên quan). Không build luồng này (entity `ConsumableRequest` hoặc tương đương) tới khi có câu trả lời — chỉ mới build phần catalog (`AssetCategory`, `ConsumableItem`).
## Vendor (chốt 2026-08-02)
Entity mới, độc lập với `AssetModel`.
- Field: `id`, `name`, `contactEmail`, `contactPhone`, `address`, `taxCode` (nullable — mã số thuế VN), `notes` (nullable).
- **Phân biệt quan trọng:** `Vendor` (nơi mua hàng — vd nhà phân phối/reseller VN) là khái niệm **khác** `AssetModel.manufacturer` (hãng sản xuất/OEM, vd "Dell") — không phải lúc nào cũng cùng 1 công ty (vd mua laptop Dell qua reseller "FPT Trading", không mua thẳng từ Dell). Quyết định: giữ nguyên `AssetModel.manufacturer` là string tự do như hiện tại (không đổi gì); `Vendor` chỉ dùng cho quan hệ mua sắm (`PurchaseOrder`, `License`).
- **Retrofit `License.vendor`** (hiện là string tự do) → **`License.vendorId`** (FK → `Vendor`), theo đúng tiền lệ đã có trong codebase (`Request.employeeId` string→FK khi `Employee` ra đời, `AssetItem.model` string→FK khi `AssetModel` ra đời — xem "Trạng thái hiện tại" ở `../CLAUDE.md`). Chưa có `License` thật nào trong DB production nên đây là đổi kiểu cột thuần tuý, **không cần chiến lược backfill** — giống các retrofit trước đó.
**Ràng buộc dữ liệu:**
- `License.vendorId` bắt buộc, phải trỏ tới 1 `Vendor` có thật (thay cho field `vendor` string tự do trước đây).
## PurchaseOrder (gộp Receipt) (chốt 2026-08-02)
**Quyết định: gộp `Receipt` vào `PurchaseOrder`, không giữ là entity riêng nữa** — nhận hàng chỉ là 1 phần trạng thái của chính PO đó (`receivedAt`/`receivedBy`), không cần 1 bảng riêng để đối chiếu. Row `Receipt` trong bảng Data model ở trên đã được xoá, gộp vào row `PurchaseOrder`.
- Field: `id`, `vendorId` (FK → `Vendor`), `orderDate`, `expectedDate`, `receivedAt` (nullable DateTime — null nghĩa là chưa nhận), `receivedBy` (nullable FK → `Employee`, set cùng lúc với `receivedAt`).
- Cố tình **không** có field giá/chi phí — stakeholder nói rõ `PurchaseOrder` không cần track tiền, chỉ cần trả lời "asset/license nào thuộc PO nào".
- Cố tình **không** có field `PurchaseOrder.status` riêng — trạng thái suy ra từ trạng thái của các `Request` liên kết (`Request` đã có state machine `Ordered → Received → Delivered` riêng), tránh 2 nguồn sự thật.
- **Nhận hàng là all-or-nothing theo từng PO** ("nhận 1 lần đủ cho cả PO") — không hỗ trợ nhận nhiều đợt/từng phần. Đây là đánh đổi đơn giản hoá **có chủ ý**, stakeholder đã biết rõ và chấp nhận có thể cần đổi lại sau (paraphrase: "giờ làm đơn giản vậy trước, sau này cần đổi thì tính tiếp") — ghi lại rõ đây là tradeoff/assumption đã note, không phải bỏ sót khi viết spec.
- **Traceability** (đúng yêu cầu cốt lõi của stakeholder: "chỉ cần biết asset nằm trong PO nào"): thêm `purchaseOrderId` (nullable FK → `PurchaseOrder`) ở **CẢ BA** chỗ — `AssetItem`, `License`, và `Request`. Ba field này phục vụ mục đích khác nhau, không dư thừa:
- `Request.purchaseOrderId` — chiều cầu (demand-side): request nào đã duyệt được gộp vào PO này (đây vốn là mục đích ban đầu đã note trong doc cho `PurchaseOrder`: "gộp nhiều Request đã duyệt").
- `AssetItem.purchaseOrderId` và `License.purchaseOrderId` — chiều cung (supply-side): record tồn kho/thực tế nào tới qua PO này. Set lúc intake/tạo mới. Nullable vì không phải `AssetItem`/`License` nào cũng đi qua 1 PO chính thức (vd tồn kho lịch sử nhập trước khi có hệ thống).
**Ràng buộc dữ liệu:**
- `PurchaseOrder.vendorId` bắt buộc, trỏ tới 1 `Vendor` có thật.
- `PurchaseOrder.receivedBy` chỉ set cùng lúc với `receivedAt` (cả hai cùng null hoặc cùng có giá trị).
- `AssetItem.purchaseOrderId`, `License.purchaseOrderId`, `Request.purchaseOrderId` đều nullable, trỏ tới `PurchaseOrder` nếu có.
- `PurchaseOrder` không có field `status` — suy ra qua `Request` liên kết, không lưu trùng.
## Assignment (chốt 2026-08-02)
- Field: `id`, `employeeId` (FK → `Employee`), `assetItemId` (FK → `AssetItem`, nullable), `licenseId` (FK → `License`, nullable), `assignedAt`, `returnedAt` (nullable — null nghĩa đang giữ), `signedAt` (nullable DateTime — null nghĩa biên bản chưa ký. Cố tình chỉ là 1 timestamp đánh dấu đã ký/chưa, **KHÔNG** phải upload file — stakeholder đã quyết định rõ không cần lưu file scan lúc này, "biên bản ký nhận" chỉ cần đánh dấu đã ký + ngày ký).
- Đúng 1 trong 2 (`assetItemId`/`licenseId`) phải được set — không cả hai, không cả hai đều rỗng (XOR) — validate ở tầng Zod qua `.refine()`, theo đúng cách codebase đã xử lý validate cross-field (không phải DB CHECK constraint, đúng nguyên tắc "không raw SQL cho app logic" — Prisma schema cũng không diễn tả được constraint XOR này natively).
- Không phải entity mới, nhưng có các business rule tầng application cần note lại rõ ở đây (đủ quan trọng để phát sinh bug nếu bỏ sót, nhưng là rule app phải tự enforce, không phải bản thân là 1 entity):
- 1 `AssetItem` chỉ có tối đa 1 `Assignment` **active** (`returnedAt IS NULL`) tại 1 thời điểm — không thể bị giữ bởi 2 người cùng lúc. Enforce trước khi tạo `Assignment` mới.
- Assign 1 `License` phải **tăng** `License.seatsUsed`, và phải **bị từ chối** nếu `seatsUsed >= seatsTotal` (không overbook). Trả lại (set `returnedAt`) **giảm** `seatsUsed`. Toàn bộ phải chạy trong 1 transaction — đây chính là rủi ro seat-count race condition mà `code-reviewer` đã note trước đó cho `License` (xem "Trạng thái hiện tại" ở `../CLAUDE.md`, mục `License`) — `Assignment` là chỗ thực sự implement/exercise seat counting lần đầu tiên.
- Chuyển tài sản giữa nhân viên (case đặc biệt đã note ở trên: "Tài sản luân chuyển giữa phòng ban / chi nhánh") = đóng `Assignment` cũ (`returnedAt = now`) + tạo `Assignment` mới, cả hai trong 1 transaction. Không cần entity "Transfer" riêng.
- `AssetItem.status` phải đồng bộ theo trạng thái `Assignment`: tạo `Assignment` → status `assigned`; trả lại → status `in_stock`, trừ nhánh RMA/sửa chữa đã note ở "Case đặc biệt cần xử lý" (đó là case riêng, không đi qua path trả-lại-thông-thường này).
- **Chốt 2026-08-02 (trả lời 2 câu hỏi mở của `pm`):** (a) `createAssignment()` phải **chặn** nếu `AssetItem.status` hiện tại khác `in_stock` (vd không cho assign 1 item đang `repair`/`retired`) — trả lỗi 400, không chỉ dựa vào điều kiện "chưa có Assignment active nào". (b) Route "update" (trả lại/chuyển giao `Assignment`, nhận hàng `PurchaseOrder`) dùng `PATCH /api/<entity>/[id]` — tiền lệ REST-shape đầu tiên cho update trong codebase, áp dụng thống nhất cho cả `Assignment` và `PurchaseOrder`, không dùng action-based sub-route.
**Ràng buộc dữ liệu:**
- `Assignment.employeeId` bắt buộc, trỏ tới 1 `Employee` có thật.
- Đúng 1 trong `assetItemId`/`licenseId` được set (XOR) — validate Zod `.refine()`.
- Không được có > 1 `Assignment` active cho cùng 1 `AssetItem` tại 1 thời điểm (enforce ở tầng application, không phải DB constraint).
- Tạo `Assignment` bị chặn nếu `AssetItem.status` khác `in_stock` (400).
- `License.seatsUsed` tăng/giảm đồng bộ trong 1 transaction khi assign/return; assign bị chặn nếu `seatsUsed >= seatsTotal`.
## Module tương lai: Rack/Slot management + IoT (chưa build, chỉ note kiến trúc) ## Module tương lai: Rack/Slot management + IoT (chưa build, chỉ note kiến trúc)
Mục tiêu xa hơn: biết chính xác asset nằm ở ô/slot nào trên tủ rack, và hướng dẫn người lấy đúng ô bằng đèn (pick-to-light). Mục tiêu xa hơn: biết chính xác asset nằm ở ô/slot nào trên tủ rack, và hướng dẫn người lấy đúng ô bằng đèn (pick-to-light).
@@ -93,7 +169,8 @@ QR gán asset vào slot (bước 1, thủ công hàng ngày) **không phải IoT
## Việc còn dở ## Việc còn dở
- Thiết kế ERD chi tiết + wireframe màn hình - Thiết kế ERD chi tiết + wireframe màn hình
- Entity còn lại: `PurchaseOrder`, `Receipt`, `Assignment` (`Employee`, `AssetItem` đã xong 2026-07-31; `License` xong 2026-08-02, xem `../CLAUDE.md`) - **`Vendor`, `PurchaseOrder` (gộp `Receipt`), `Assignment`, `AssetCategoryAttributeDef` (2026-08-02): đã có spec đầy đủ (BA), sẵn sàng cho `pm` chia task** — xem các mục "Vendor", "PurchaseOrder (gộp Receipt)", "Assignment" và "Cập nhật 2026-08-02 (3)" trong "Phân loại tài sản" ở trên. Bao gồm cả retrofit `License.vendor` (string) → `License.vendorId` (FK → `Vendor`, không cần backfill vì chưa có `License` thật trong DB) và thêm `purchaseOrderId` (nullable FK) vào `AssetItem`/`License`/`Request`. Thứ tự phụ thuộc gợi ý cho `pm`: `Vendor` → retrofit `License.vendorId` → `PurchaseOrder` (+ `purchaseOrderId` trên 3 entity) → `Assignment`; `AssetCategoryAttributeDef` độc lập, có thể làm song song.
- Consumable (2026-08-02): phần phân loại (`AssetCategory`, `ConsumableItem`) đã có spec đầy đủ (BA), sẵn sàng cho `pm` chia task. Luồng "xin + nhận" (`ConsumableRequest` hoặc tương đương) **BỊ CHẶN** — cần stakeholder trả lời các câu hỏi mở: (1) có cần approval không, ai duyệt; (2) ai được tạo request xin (tự xin cho mình hay xin hộ được); (3) có giới hạn số lượng tối đa/lần xin không; (4) hết hàng thì chặn hẳn / cho "nợ" chờ nhập thêm / fulfill một phần; (5) trừ tồn kho lúc tạo request hay lúc fulfill; (6) restock tồn kho qua luồng nào, ai được làm; (7) có cần ghi nhận người phát hàng/ký nhận không. Không tự đoán — xem chi tiết đầy đủ trong spec BA.
- Migrate 2 request đang có (`Firefly_License_Request.docx`, `Eric Pelatan.docx`, ở workspace `ubisoft/` ngoài repo này) vào hệ thống mới sau khi có DB - Migrate 2 request đang có (`Firefly_License_Request.docx`, `Eric Pelatan.docx`, ở workspace `ubisoft/` ngoài repo này) vào hệ thống mới sau khi có DB
- (Xa hơn, chưa ưu tiên) Rack/Slot + IoT pick-to-light, cron email cảnh báo license hết hạn, Power BI report views - (Xa hơn, chưa ưu tiên) Rack/Slot + IoT pick-to-light, cron email cảnh báo license hết hạn, Power BI report views
- Setup Gitea tự host ở nhà (2026-07-31) — dự định làm remote chính sau này, giữ GitHub làm backup off-site (đây là "sản phẩm dùng cả đời" của user, không nên chỉ có 1 bản duy nhất). Tạm thời vẫn push GitHub như hiện tại, chưa làm gấp. - Setup Gitea tự host ở nhà (2026-07-31) — dự định làm remote chính sau này, giữ GitHub làm backup off-site (đây là "sản phẩm dùng cả đời" của user, không nên chỉ có 1 bản duy nhất). Tạm thời vẫn push GitHub như hiện tại, chưa làm gấp.
@@ -103,4 +180,4 @@ QR gán asset vào slot (bước 1, thủ công hàng ngày) **không phải IoT
- Migrate entity `Request` từ SQLite sang Postgres (2026-07-30) và setup ESLint đều đã **CONFIRMED xong** qua lượt verify độc lập (test-runner riêng, không phải agent vừa code tự chấm) — 6 test pass trên Postgres thật, lint sạch, type-check sạch. Chi tiết trạng thái nằm ở `../CLAUDE.md`, không duplicate ở đây. - Migrate entity `Request` từ SQLite sang Postgres (2026-07-30) và setup ESLint đều đã **CONFIRMED xong** qua lượt verify độc lập (test-runner riêng, không phải agent vừa code tự chấm) — 6 test pass trên Postgres thật, lint sạch, type-check sạch. Chi tiết trạng thái nằm ở `../CLAUDE.md`, không duplicate ở đây.
--- ---
Cập nhật lần cuối: 2026-08-02 Cập nhật lần cuối: 2026-08-02 (spec `Vendor`, `PurchaseOrder` gộp `Receipt`, `Assignment`, `AssetCategoryAttributeDef` — sẵn sàng cho `pm` chia task)
@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "vendors" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"contact_email" TEXT NOT NULL,
"contact_phone" TEXT NOT NULL,
"address" TEXT NOT NULL,
"tax_code" TEXT,
"notes" TEXT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "vendors_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `vendor` on the `licenses` table. All the data in the column will be lost.
- Added the required column `vendor_id` to the `licenses` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "licenses" DROP COLUMN "vendor",
ADD COLUMN "vendor_id" UUID NOT NULL;
-- AddForeignKey
ALTER TABLE "licenses" ADD CONSTRAINT "licenses_vendor_id_fkey" FOREIGN KEY ("vendor_id") REFERENCES "vendors"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "purchase_orders" (
"id" UUID NOT NULL,
"vendor_id" UUID NOT NULL,
"order_date" TIMESTAMPTZ(6) NOT NULL,
"expected_date" TIMESTAMPTZ(6) NOT NULL,
"received_at" TIMESTAMPTZ(6),
"received_by" UUID,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "purchase_orders_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "purchase_orders" ADD CONSTRAINT "purchase_orders_vendor_id_fkey" FOREIGN KEY ("vendor_id") REFERENCES "vendors"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "purchase_orders" ADD CONSTRAINT "purchase_orders_received_by_fkey" FOREIGN KEY ("received_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "asset_items" ADD COLUMN "purchase_order_id" UUID;
-- AddForeignKey
ALTER TABLE "asset_items" ADD CONSTRAINT "asset_items_purchase_order_id_fkey" FOREIGN KEY ("purchase_order_id") REFERENCES "purchase_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "licenses" ADD COLUMN "purchase_order_id" UUID;
-- AddForeignKey
ALTER TABLE "licenses" ADD CONSTRAINT "licenses_purchase_order_id_fkey" FOREIGN KEY ("purchase_order_id") REFERENCES "purchase_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "requests" ADD COLUMN "purchase_order_id" UUID;
-- AddForeignKey
ALTER TABLE "requests" ADD CONSTRAINT "requests_purchase_order_id_fkey" FOREIGN KEY ("purchase_order_id") REFERENCES "purchase_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- CreateEnum
CREATE TYPE "AssetCategoryValueGroup" AS ENUM ('high_value', 'low_value');
-- CreateTable
CREATE TABLE "asset_categories" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"value_group" "AssetCategoryValueGroup" NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "asset_categories_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,22 @@
-- CreateEnum
CREATE TYPE "AssetAttributeDataType" AS ENUM ('string', 'number', 'boolean', 'date');
-- CreateTable
CREATE TABLE "asset_category_attribute_defs" (
"id" UUID NOT NULL,
"category_id" UUID NOT NULL,
"key" TEXT NOT NULL,
"label" TEXT NOT NULL,
"data_type" "AssetAttributeDataType" NOT NULL,
"required" BOOLEAN NOT NULL,
"sort_order" INTEGER NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "asset_category_attribute_defs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "asset_category_attribute_defs_category_id_key_key" ON "asset_category_attribute_defs"("category_id", "key");
-- AddForeignKey
ALTER TABLE "asset_category_attribute_defs" ADD CONSTRAINT "asset_category_attribute_defs_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "asset_categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "asset_models" (
"id" UUID NOT NULL,
"category_id" UUID NOT NULL,
"manufacturer" TEXT NOT NULL,
"name" TEXT NOT NULL,
"specs" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "asset_models_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "asset_models" ADD CONSTRAINT "asset_models_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "asset_categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `model` on the `asset_items` table. All the data in the column will be lost.
- Added the required column `model_id` to the `asset_items` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "asset_items" DROP COLUMN "model",
ADD COLUMN "model_id" UUID NOT NULL;
-- AddForeignKey
ALTER TABLE "asset_items" ADD CONSTRAINT "asset_items_model_id_fkey" FOREIGN KEY ("model_id") REFERENCES "asset_models"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "assignments" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"asset_item_id" UUID,
"license_id" UUID,
"assigned_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"returned_at" TIMESTAMPTZ(6),
"signed_at" TIMESTAMPTZ(6),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "assignments_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_asset_item_id_fkey" FOREIGN KEY ("asset_item_id") REFERENCES "asset_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_license_id_fkey" FOREIGN KEY ("license_id") REFERENCES "licenses"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+292 -14
View File
@@ -40,6 +40,15 @@ model Request {
// plain string (not an enum) until more states exist — see // plain string (not an enum) until more states exist — see
// src/lib/requests.ts's RequestStatus type. // src/lib/requests.ts's RequestStatus type.
status String @default("Draft") status String @default("Draft")
// Nullable FK to PurchaseOrder — see docs/asset-license-management.md's
// "## PurchaseOrder (gộp Receipt)" Traceability bullet. Semantically the
// DEMAND side, unlike AssetItem.purchaseOrderId/License.purchaseOrderId
// (supply-side intake): this records "which approved Request got grouped
// into this PO", not "which PO did this item come in through". Shape is
// identical to those two though — nullable FK, set at creation time only,
// no update endpoint for this field yet, same P2003-on-insert handling.
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("requests") @@map("requests")
@@ -58,6 +67,13 @@ model Employee {
manager Employee? @relation("EmployeeManager", fields: [managerId], references: [id]) manager Employee? @relation("EmployeeManager", fields: [managerId], references: [id])
reports Employee[] @relation("EmployeeManager") reports Employee[] @relation("EmployeeManager")
requests Request[] requests Request[]
// Back-relation for PurchaseOrder.receivedById — an Employee can be the
// receiver on multiple POs over time.
purchaseOrdersReceived PurchaseOrder[]
// Back-relation for Assignment.employeeId — an Employee can hold multiple
// Assignments (assets/licenses) over time, including several active ones
// at once (e.g. a laptop + a license seat simultaneously).
assignments Assignment[]
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("employees") @@map("employees")
@@ -75,19 +91,40 @@ enum AssetItemStatus {
// Hardware inventory — see docs/asset-license-management.md's "Data model // Hardware inventory — see docs/asset-license-management.md's "Data model
// (sơ bộ)" table (serial number, model, ngày mua, bảo hành, trạng thái). // (sơ bộ)" table (serial number, model, ngày mua, bảo hành, trạng thái).
// No Vendor/Assignment relation yet — those entities don't exist yet // No Assignment relation yet — that entity doesn't exist yet (see "Việc còn
// (see "Việc còn dở" in CLAUDE.md), so this is a standalone entity for now, // dở" in CLAUDE.md). warrantyEndDate is nullable: not every asset has known
// same starting shape as Employee before Request grew an FK to it. // warranty terms at intake time (smallest sensible assumption — doesn't
// warrantyEndDate is nullable: not every asset has known warranty terms at // block creation). purchaseOrderId is a nullable FK to PurchaseOrder — see
// intake time (smallest sensible assumption — doesn't block creation). // docs/asset-license-management.md's "## PurchaseOrder (gộp Receipt)"
// Traceability bullet: supply-side traceability ("which PO did this asset
// item come in through"). Nullable because not every AssetItem goes through
// a formal PO (e.g. historical stock predating the system). Set at
// intake/creation time only — no update endpoint for this field yet, same
// precedent as License.vendorId being set-at-creation with no PATCH.
// `modelId` was a free-text `model` String until the "Cập nhật 2026-08-02
// (2)" retrofit (docs/asset-license-management.md's "Ràng buộc dữ liệu"
// bullet) — same precedent as Request.employeeId/License.vendorId (string
// -> FK once the referenced entity existed). No production `AssetItem` data
// existed at the time of this change, so this was a plain column-type
// migration, not a backfill. Required (not nullable) — every AssetItem must
// belong to a real AssetModel. AssetItem deliberately does NOT carry its own
// `categoryId` — category is derived via `modelId -> AssetModel.categoryId`
// to avoid two sources of truth that could drift apart.
model AssetItem { model AssetItem {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
serialNumber String @unique @map("serial_number") serialNumber String @unique @map("serial_number")
model String modelId String @map("model_id") @db.Uuid
model AssetModel @relation(fields: [modelId], references: [id])
purchaseDate DateTime @map("purchase_date") @db.Date purchaseDate DateTime @map("purchase_date") @db.Date
warrantyEndDate DateTime? @map("warranty_end_date") @db.Date warrantyEndDate DateTime? @map("warranty_end_date") @db.Date
status AssetItemStatus @default(in_stock) status AssetItemStatus @default(in_stock)
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Back-relation for Assignment.assetItemId — an AssetItem can have
// multiple Assignment rows over its lifetime (historical + at most one
// active, see Assignment model's comment), not built until this task.
assignments Assignment[]
@@map("asset_items") @@map("asset_items")
} }
@@ -102,26 +139,267 @@ enum LicenseType {
// Software license inventory — see docs/asset-license-management.md's "Data // Software license inventory — see docs/asset-license-management.md's "Data
// model (sơ bộ)" table (tên, vendor, tổng seat, seat đã dùng, ngày hết hạn, // model (sơ bộ)" table (tên, vendor, tổng seat, seat đã dùng, ngày hết hạn,
// loại). `vendor` is deliberately a plain String for now, not a separate // loại). `vendorId` was a free-text `vendor` String until the "## Vendor
// Vendor entity — no Vendor entity exists yet, same "plain string for now" // (chốt 2026-08-02)" retrofit — same precedent as Request.employeeId
// precedent as Employee.department. `seatsUsed` is a stored counter // (string -> FK once Employee existed). No production `License` data
// (defaults to 0 at creation), not a computed value — it will only ever be // existed at the time of this change, so this was a plain column-type
// set to 0 here since Assignment (the thing that would consume seats) // migration, not a backfill. `seatsUsed` is a stored counter (defaults to 0
// doesn't exist yet. `expiryDate` is nullable: perpetual licenses never // at creation), not a computed value — it will only ever be set to 0 here
// expire, only subscription licenses usually have one. // since Assignment (the thing that would consume seats) doesn't exist yet.
// `expiryDate` is nullable: perpetual licenses never expire, only
// subscription licenses usually have one. `purchaseOrderId` is a nullable FK
// to PurchaseOrder — see docs/asset-license-management.md's "## PurchaseOrder
// (gộp Receipt)" Traceability bullet: supply-side traceability ("which PO
// did this license come in through"). Nullable because not every License
// goes through a formal PO. Mirrors AssetItem.purchaseOrderId's just-merged
// pattern exactly — same nullable-FK shape, set at creation time only, no
// update endpoint for this field yet.
model License { model License {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
name String name String
vendor String vendorId String @map("vendor_id") @db.Uuid
vendor Vendor @relation(fields: [vendorId], references: [id])
seatsTotal Int @map("seats_total") seatsTotal Int @map("seats_total")
seatsUsed Int @default(0) @map("seats_used") seatsUsed Int @default(0) @map("seats_used")
expiryDate DateTime? @map("expiry_date") @db.Date expiryDate DateTime? @map("expiry_date") @db.Date
licenseType LicenseType @map("license_type") licenseType LicenseType @map("license_type")
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Back-relation for Assignment.licenseId — a License can have multiple
// Assignment rows (one per seat handed out, plus historical returns), not
// built until this task.
assignments Assignment[]
@@map("licenses") @@map("licenses")
} }
// Purchasing counterparty — see docs/asset-license-management.md's "## Vendor
// (chốt 2026-08-02)". Deliberately independent of AssetModel.manufacturer
// (OEM, e.g. "Dell") — Vendor is the reseller/distributor actually bought
// from (e.g. "FPT Trading"), a different concept that isn't always the same
// company. `licenses` is the back-relation from the License.vendor ->
// License.vendorId retrofit (same doc section). `purchaseOrders` is the
// back-relation from PurchaseOrder.vendorId (see "## PurchaseOrder (gộp
// Receipt) (chốt 2026-08-02)"). taxCode/notes are nullable — not every
// vendor record has a known VN tax code or extra notes at creation time.
model Vendor {
id String @id @default(uuid()) @db.Uuid
name String
contactEmail String @map("contact_email")
contactPhone String @map("contact_phone")
address String
taxCode String? @map("tax_code")
notes String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
licenses License[]
purchaseOrders PurchaseOrder[]
@@map("vendors")
}
// Purchasing document — see docs/asset-license-management.md's "##
// PurchaseOrder (gộp Receipt) (chốt 2026-08-02)". Deliberately absorbs what
// would otherwise be a separate `Receipt` entity: receiving is just part of
// this row's own state (`receivedAt`/`receivedBy`), not a separate table to
// reconcile against. No `status` field — status is inferred from linked
// `Request`s (Ordered -> Received -> Delivered), not stored here, to avoid
// two sources of truth. No cost/price fields — stakeholder said explicitly
// PurchaseOrder doesn't need to track money, only "which asset/license
// belongs to which PO". Receiving is all-or-nothing per PO (no partial
// receipt tracking) — a deliberate simplification, see doc for the
// stakeholder-accepted tradeoff. `receivedAt`/`receivedBy` are both
// nullable and must be co-null/co-set together (both-null = not yet
// received) — enforced at the Zod layer (src/lib/purchase-orders.ts), not a
// DB constraint, same approach as other cross-field invariants in this
// codebase (e.g. Assignment's assetItemId/licenseId XOR, not built yet).
// `assetItems` is the back-relation from the AssetItem.purchaseOrderId
// retrofit (supply-side traceability, same doc section). `licenses` is the
// back-relation from the License.purchaseOrderId retrofit (same doc
// section, same rationale). `requests` is the back-relation from the
// Request.purchaseOrderId retrofit — the DEMAND side ("which approved
// Request got grouped into this PO"), as opposed to assetItems/licenses
// which are the SUPPLY side (intake traceability) — see Request model's
// comment above for the distinction.
model PurchaseOrder {
id String @id @default(uuid()) @db.Uuid
vendorId String @map("vendor_id") @db.Uuid
vendor Vendor @relation(fields: [vendorId], references: [id])
orderDate DateTime @map("order_date") @db.Timestamptz(6)
expectedDate DateTime @map("expected_date") @db.Timestamptz(6)
receivedAt DateTime? @map("received_at") @db.Timestamptz(6)
receivedById String? @map("received_by") @db.Uuid
receivedBy Employee? @relation(fields: [receivedById], references: [id])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
assetItems AssetItem[]
licenses License[]
requests Request[]
@@map("purchase_orders")
}
// Fixed, closed set of 2 values tied to the domain (see docs/
// asset-license-management.md's "## Phân loại tài sản: High-value vs
// Low-value/Consumable" section) — native Postgres enum, same rationale as
// RequestType/AssetItemStatus/LicenseType above. Determines downstream
// rules for entities that will attach to a category later (not built in
// this task): AssetModel.categoryId must point to a `high_value` category,
// ConsumableItem.categoryId must point to a `low_value` category.
enum AssetCategoryValueGroup {
high_value
low_value
}
// Asset classification catalog — see docs/asset-license-management.md's
// "## Phân loại tài sản" section and the `AssetCategory` row in the "Data
// model (sơ bộ)" table. Standalone entity, no FKs (same starting shape as
// Vendor before it grew relations) — `AssetModel` and
// `AssetCategoryAttributeDef` will attach to this via `categoryId` in
// later tasks (not this one). IT admin defines the concrete categories
// (e.g. "Laptop", "Cáp HDMI") freely; only `valueGroup` is a fixed 2-value
// set.
model AssetCategory {
id String @id @default(uuid()) @db.Uuid
name String
valueGroup AssetCategoryValueGroup @map("value_group")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
attributeDefs AssetCategoryAttributeDef[]
// Back-relation for AssetModel.categoryId — see "Cập nhật 2026-08-02 (2)"
// in docs/asset-license-management.md. Only categories with
// valueGroup=high_value are ever actually referenced by an AssetModel (a
// business rule enforced in src/lib/asset-models.ts's createAssetModel(),
// not expressible as a Prisma-level constraint), but the relation itself
// is not restricted to high_value categories at the schema level.
assetModels AssetModel[]
@@map("asset_categories")
}
// Fixed, closed set of 4 values — a DEV-defined technical set (which Zod
// validator to build dynamically for AssetModel.specs at write time: string
// -> z.string(), number -> z.number(), boolean -> z.boolean(), date ->
// z.coerce.date()). Deliberately a different kind of "fixed set" than
// AssetCategoryValueGroup above: that one is a closed business classification
// IT admins never extend; this one is a closed set of *primitive types* the
// app's dynamic-attribute machinery knows how to handle. Still modeled as a
// native Postgres enum per the same codebase convention (RequestType /
// AssetItemStatus / LicenseType / AssetCategoryValueGroup), not a String +
// hand-rolled check.
enum AssetAttributeDataType {
string
number
boolean
date
}
// Per-category dynamic attribute definitions — see docs/
// asset-license-management.md's "Cập nhật 2026-08-02 (3): thêm
// AssetCategoryAttributeDef" section and the `AssetCategoryAttributeDef` row
// in the "Data model (sơ bộ)" table. Defines the *shape* of attributes a
// category cares about (e.g. category "Laptop" -> key `ram_gb`, label
// "RAM (GB)", dataType number); the actual *values* live in
// AssetModel.specs (JSONB), not built in this task. Each AssetCategory owns
// its own independent set of attribute defs — deliberately NOT a shared
// attribute library across categories (stakeholder explicitly rejected that
// design to keep things simple at current scale), so the same `key` string
// existing under two different categoryId rows is normal, not a collision —
// only unique *within* a category, hence the composite unique below (not a
// plain @unique on key alone). `sortOrder` is the display order on the
// dynamic form, no uniqueness constraint of its own (ties/gaps are fine,
// it's just a sort key).
model AssetCategoryAttributeDef {
id String @id @default(uuid()) @db.Uuid
categoryId String @map("category_id") @db.Uuid
category AssetCategory @relation(fields: [categoryId], references: [id])
key String
label String
dataType AssetAttributeDataType @map("data_type")
required Boolean
sortOrder Int @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@unique([categoryId, key])
@@map("asset_category_attribute_defs")
}
// Sits between AssetCategory and AssetItem — see docs/
// asset-license-management.md's "Cập nhật 2026-08-02 (2)" section and the
// `AssetModel` row in the "Data model (sơ bộ)" table. 1 AssetModel has many
// AssetItem serials (AssetItem.modelId FK, not built in this task).
// `manufacturer`/`name` are deliberately two separate String fields (not
// merged into one), per the doc, to allow filtering/reporting by
// manufacturer alone (e.g. "Dell") without string-parsing a combined field.
// `categoryId` is a required FK to AssetCategory — the DB layer doesn't
// enforce that the referenced category is `high_value` (Prisma/Postgres FK
// constraints can't express that), that business rule is checked in
// src/lib/asset-models.ts's createAssetModel() via an explicit lookup
// before the create, per the doc's "Ràng buộc dữ liệu" section. `specs` is
// JSONB, defaulting to an empty object — the real per-category dynamic
// attribute *values* (whose *shape* is defined by
// AssetCategoryAttributeDef rows for this model's category). This task
// deliberately does NOT validate specs against the category's attribute
// defs (loose z.record(z.unknown()) validation only, see
// src/lib/asset-models.ts) — that dynamic cross-validation is a separate
// follow-up task per the doc's "Cập nhật 2026-08-02 (3)" section.
model AssetModel {
id String @id @default(uuid()) @db.Uuid
categoryId String @map("category_id") @db.Uuid
category AssetCategory @relation(fields: [categoryId], references: [id])
manufacturer String
name String
specs Json @default("{}")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Back-relation for the AssetItem.model -> AssetItem.modelId retrofit —
// see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md.
assetItems AssetItem[]
@@map("asset_models")
}
// Assigns an AssetItem (high-value hardware) or a License seat to a specific
// Employee — see docs/asset-license-management.md's "## Assignment (chốt
// 2026-08-02)" section and the `Assignment` row in the "Data model (sơ bộ)"
// table. Exactly one of assetItemId/licenseId must be set (XOR) — enforced
// at the Zod layer (src/lib/assignments.ts's `.refine()`), NOT a DB
// constraint, per the same convention as PurchaseOrder's
// receivedAt/receivedById co-nullability check: Prisma schema can't express
// an XOR between two nullable FKs natively. Both FKs stay plain nullable
// columns here for that reason. `assignedAt` defaults to now() but is a
// real stored column (not derived from createdAt) because a future
// "transfer" operation (out of scope for this task, see doc) will create a
// new Assignment row whose assignedAt should reflect the transfer moment,
// not necessarily row-insert time. `returnedAt` null means still held by
// the employee — this is also what "active assignment" means throughout
// the codebase (see the uniqueness rule below). `signedAt` is deliberately
// just a nullable timestamp marking "handover paperwork signed" — NO file
// upload, stakeholder explicitly decided against storing a scanned file at
// this stage (see doc). No `@@unique` on assetItemId here for "only one
// active assignment at a time" — a plain unique constraint would also
// forbid a *second, already-returned* historical Assignment for the same
// AssetItem (e.g. item reassigned after being returned), which is valid and
// expected. The "at most one ACTIVE (returnedAt IS NULL) assignment per
// AssetItem" invariant is instead enforced in application code
// (src/lib/assignments.ts's createAssignment(), inside the same transaction
// that mutates AssetItem.status/License.seatsUsed) — a partial unique index
// (`WHERE returned_at IS NULL`) would express this at the DB level too, but
// that's raw SQL outside a Prisma-expressible `@@unique`, so deliberately
// not added here; see feature-implementer report for this trade-off.
model Assignment {
id String @id @default(uuid()) @db.Uuid
employeeId String @map("employee_id") @db.Uuid
employee Employee @relation(fields: [employeeId], references: [id])
assetItemId String? @map("asset_item_id") @db.Uuid
assetItem AssetItem? @relation(fields: [assetItemId], references: [id])
licenseId String? @map("license_id") @db.Uuid
license License? @relation(fields: [licenseId], references: [id])
assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(6)
returnedAt DateTime? @map("returned_at") @db.Timestamptz(6)
signedAt DateTime? @map("signed_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("assignments")
}
// Minimal internal auth accounts — NOT company SSO (deliberately ruled out, // Minimal internal auth accounts — NOT company SSO (deliberately ruled out,
// see CLAUDE.md / auth decision notes). A handful of trusted IT staff // see CLAUDE.md / auth decision notes). A handful of trusted IT staff
// accounts, created by hand via scripts/create-user.ts, no self-registration. // accounts, created by hand via scripts/create-user.ts, no self-registration.
+51
View File
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {
createAssetCategory,
listAssetCategories,
validateCreateAssetCategoryInput,
} from "@/lib/asset-categories";
// Mirrors src/app/api/licenses/route.ts's pattern — middleware.ts excludes
// /api from route protection, so this checks the session itself. Flat
// permission model (CLAUDE.md): only "logged in or not", no role check.
export async function POST(request: Request): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
}
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { name, valueGroup } = body as Record<string, unknown>;
const validation = validateCreateAssetCategoryInput({ name, valueGroup });
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createAssetCategory(validation.value);
if (!created.ok) {
return NextResponse.json({ error: created.error }, { status: 400 });
}
return NextResponse.json(created.value, { status: 201 });
}
export async function GET(): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const assetCategories = await listAssetCategories();
return NextResponse.json(assetCategories, { status: 200 });
}
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {
createAssetCategoryAttributeDef,
listAssetCategoryAttributeDefs,
validateCreateAssetCategoryAttributeDefInput,
} from "@/lib/asset-category-attribute-defs";
// Mirrors src/app/api/asset-categories/route.ts's pattern — middleware.ts
// excludes /api from route protection, so this checks the session itself.
// Flat permission model (CLAUDE.md): only "logged in or not", no role check.
export async function POST(request: Request): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
}
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { categoryId, key, label, dataType, required, sortOrder } =
body as Record<string, unknown>;
const validation = validateCreateAssetCategoryAttributeDefInput({
categoryId,
key,
label,
dataType,
required,
sortOrder,
});
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createAssetCategoryAttributeDef(validation.value);
if (!created.ok) {
return NextResponse.json({ error: created.error }, { status: 400 });
}
return NextResponse.json(created.value, { status: 201 });
}
export async function GET(): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const assetCategoryAttributeDefs = await listAssetCategoryAttributeDefs();
return NextResponse.json(assetCategoryAttributeDefs, { status: 200 });
}
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {
createAssetModel,
listAssetModels,
validateCreateAssetModelInput,
} from "@/lib/asset-models";
// Mirrors src/app/api/asset-category-attribute-defs/route.ts's pattern —
// middleware.ts excludes /api from route protection, so this checks the
// session itself. Flat permission model (CLAUDE.md): only "logged in or
// not", no role check.
export async function POST(request: Request): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
}
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { categoryId, manufacturer, name, specs } = body as Record<string, unknown>;
const validation = validateCreateAssetModelInput({ categoryId, manufacturer, name, specs });
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createAssetModel(validation.value);
if (!created.ok) {
return NextResponse.json({ error: created.error }, { status: 400 });
}
return NextResponse.json(created.value, { status: 201 });
}
export async function GET(): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const assetModels = await listAssetModels();
return NextResponse.json(assetModels, { status: 200 });
}
+4 -2
View File
@@ -22,12 +22,14 @@ export async function POST(request: Request): Promise<NextResponse> {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
} }
const { serialNumber, model, purchaseDate, warrantyEndDate } = body as Record<string, unknown>; const { serialNumber, modelId, purchaseDate, warrantyEndDate, purchaseOrderId } =
body as Record<string, unknown>;
const validation = validateCreateAssetItemInput({ const validation = validateCreateAssetItemInput({
serialNumber, serialNumber,
model, modelId,
purchaseDate, purchaseDate,
warrantyEndDate, warrantyEndDate,
purchaseOrderId,
}); });
if (!validation.ok) { if (!validation.ok) {
+86
View File
@@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import {
returnAssignment,
transferAssignment,
validateReturnAssignmentInput,
validateTransferAssignmentInput,
} from "@/lib/assignments";
// First dynamic-segment ([id]) route in the codebase — see
// docs/asset-license-management.md's "Assignment (chốt 2026-08-02)" section,
// bullet (b): update routes use PATCH /api/<entity>/[id], not an
// action-based sub-route (e.g. NOT /api/assignments/[id]/return). Next.js
// 14 App Router route handler params are a plain synchronous object (not a
// Promise) — that changed starting in Next.js 15, which this project does
// not use (see package.json / CLAUDE.md).
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Validate the [id] path segment's format up front — an invalid UUID
// passed straight to Prisma's `findUnique({ where: { id } })` (a `@db.Uuid`
// column) throws a raw PrismaClientKnownRequestError that
// returnAssignment()'s try/catch doesn't specifically handle, which would
// otherwise leak as an uncaught 500 instead of a clean 400.
if (!z.string().uuid().safeParse(params.id).success) {
return NextResponse.json({ error: "id must be a valid UUID string" }, { status: 400 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
}
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { returnedAt, employeeId } = body as Record<string, unknown>;
// Dispatch on body shape — a body carrying employeeId is a transfer
// request, everything else is the existing return behavior. The two are
// mutually exclusive: don't guess intent if a caller sends both (see
// docs/asset-license-management.md's "Assignment (chốt 2026-08-02)"
// section, bullet right after the transfer rule, for why this stays a
// single PATCH route rather than an action-based sub-route).
if (employeeId !== undefined && returnedAt !== undefined) {
return NextResponse.json(
{ error: "Provide either employeeId (transfer) or returnedAt (return), not both" },
{ status: 400 }
);
}
if (employeeId !== undefined) {
const transferValidation = validateTransferAssignmentInput({ employeeId });
if (!transferValidation.ok) {
return NextResponse.json({ error: transferValidation.error }, { status: 400 });
}
const transferred = await transferAssignment(params.id, transferValidation.value);
if (!transferred.ok) {
return NextResponse.json({ error: transferred.error }, { status: transferred.status });
}
return NextResponse.json(transferred.value, { status: 200 });
}
const validation = validateReturnAssignmentInput({ returnedAt });
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const updated = await returnAssignment(params.id, validation.value);
if (!updated.ok) {
return NextResponse.json({ error: updated.error }, { status: updated.status });
}
return NextResponse.json(updated.value, { status: 200 });
}
+51
View File
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {
createAssignment,
listAssignments,
validateCreateAssignmentInput,
} from "@/lib/assignments";
// Mirrors src/app/api/licenses/route.ts's pattern — middleware.ts excludes
// /api from route protection, so this checks the session itself. Flat
// permission model (CLAUDE.md): only "logged in or not", no role check.
export async function POST(request: Request): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
}
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { employeeId, assetItemId, licenseId } = body as Record<string, unknown>;
const validation = validateCreateAssignmentInput({ employeeId, assetItemId, licenseId });
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createAssignment(validation.value);
if (!created.ok) {
return NextResponse.json({ error: created.error }, { status: 400 });
}
return NextResponse.json(created.value, { status: 201 });
}
export async function GET(): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const assignments = await listAssignments();
return NextResponse.json(assignments, { status: 200 });
}
+8 -3
View File
@@ -22,13 +22,15 @@ export async function POST(request: Request): Promise<NextResponse> {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
} }
const { name, vendor, seatsTotal, expiryDate, licenseType } = body as Record<string, unknown>; const { name, vendorId, seatsTotal, expiryDate, licenseType, purchaseOrderId } =
body as Record<string, unknown>;
const validation = validateCreateLicenseInput({ const validation = validateCreateLicenseInput({
name, name,
vendor, vendorId,
seatsTotal, seatsTotal,
expiryDate, expiryDate,
licenseType, licenseType,
purchaseOrderId,
}); });
if (!validation.ok) { if (!validation.ok) {
@@ -36,7 +38,10 @@ export async function POST(request: Request): Promise<NextResponse> {
} }
const created = await createLicense(validation.value); const created = await createLicense(validation.value);
return NextResponse.json(created, { status: 201 }); if (!created.ok) {
return NextResponse.json({ error: created.error }, { status: 400 });
}
return NextResponse.json(created.value, { status: 201 });
} }
export async function GET(): Promise<NextResponse> { export async function GET(): Promise<NextResponse> {
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {
createPurchaseOrder,
listPurchaseOrders,
validateCreatePurchaseOrderInput,
} from "@/lib/purchase-orders";
// Mirrors src/app/api/licenses/route.ts's pattern — middleware.ts excludes
// /api from route protection, so this checks the session itself. Flat
// permission model (CLAUDE.md): only "logged in or not", no role check.
export async function POST(request: Request): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
}
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { vendorId, orderDate, expectedDate, receivedAt, receivedById } =
body as Record<string, unknown>;
const validation = validateCreatePurchaseOrderInput({
vendorId,
orderDate,
expectedDate,
receivedAt,
receivedById,
});
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createPurchaseOrder(validation.value);
if (!created.ok) {
return NextResponse.json({ error: created.error }, { status: 400 });
}
return NextResponse.json(created.value, { status: 201 });
}
export async function GET(): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const purchaseOrders = await listPurchaseOrders();
return NextResponse.json(purchaseOrders, { status: 200 });
}
+2 -2
View File
@@ -24,8 +24,8 @@ export async function POST(request: Request): Promise<NextResponse> {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
} }
const { employee_id, type, reason } = body as Record<string, unknown>; const { employee_id, type, reason, purchase_order_id } = body as Record<string, unknown>;
const validation = validateCreateRequestInput({ employee_id, type, reason }); const validation = validateCreateRequestInput({ employee_id, type, reason, purchase_order_id });
if (!validation.ok) { if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 }); return NextResponse.json({ error: validation.error }, { status: 400 });
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { createVendor, listVendors, validateCreateVendorInput } from "@/lib/vendors";
// Mirrors src/app/api/licenses/route.ts's pattern — middleware.ts excludes
// /api from route protection, so this checks the session itself. Flat
// permission model (CLAUDE.md): only "logged in or not", no role check.
export async function POST(request: Request): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
}
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { name, contactEmail, contactPhone, address, taxCode, notes } =
body as Record<string, unknown>;
const validation = validateCreateVendorInput({
name,
contactEmail,
contactPhone,
address,
taxCode,
notes,
});
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createVendor(validation.value);
return NextResponse.json(created, { status: 201 });
}
export async function GET(): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const vendors = await listVendors();
return NextResponse.json(vendors, { status: 200 });
}
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { useState, type FormEvent } from "react";
import { useApiList } from "@/hooks/use-api-list";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Shape returned by GET /api/asset-categories — snake_case per this repo's
// API-boundary convention (see src/lib/asset-categories.ts's toRecord()).
interface AssetCategoryRecord {
id: string;
name: string;
value_group: "high_value" | "low_value";
created_at: string;
}
const VALUE_GROUP_OPTIONS = ["high_value", "low_value"] as const;
export default function AssetCategoriesPage() {
const {
data: assetCategories,
loading,
error,
refetch,
} = useApiList<AssetCategoryRecord>("/api/asset-categories");
const [name, setName] = useState("");
const [valueGroup, setValueGroup] = useState<(typeof VALUE_GROUP_OPTIONS)[number]>(
"high_value"
);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
try {
const response = await fetch("/api/asset-categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, valueGroup }),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setSubmitError(message);
return;
}
setName("");
setValueGroup("high_value");
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Asset Categories</h1>
<section className="flex flex-col gap-3">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Value Group</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assetCategories.map((assetCategory) => (
<TableRow key={assetCategory.id}>
<TableCell>{assetCategory.id}</TableCell>
<TableCell>{assetCategory.name}</TableCell>
<TableCell>{assetCategory.value_group}</TableCell>
<TableCell>{assetCategory.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Asset Category</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="valueGroup">Value Group</Label>
<Select
value={valueGroup}
onValueChange={(value) =>
setValueGroup(value as (typeof VALUE_GROUP_OPTIONS)[number])
}
>
<SelectTrigger id="valueGroup" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VALUE_GROUP_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Asset Category"}
</Button>
</form>
</section>
</div>
);
}
@@ -0,0 +1,256 @@
"use client";
import { useState, type FormEvent } from "react";
import { useApiList } from "@/hooks/use-api-list";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Shape returned by GET /api/asset-category-attribute-defs — snake_case per
// this repo's API-boundary convention (see
// src/lib/asset-category-attribute-defs.ts's toRecord()).
interface AssetCategoryAttributeDefRecord {
id: string;
category_id: string;
key: string;
label: string;
data_type: "string" | "number" | "boolean" | "date";
required: boolean;
sort_order: number;
created_at: string;
}
// Shape returned by GET /api/asset-categories — snake_case per
// src/lib/asset-categories.ts's toRecord(). Only used here to populate the
// categoryId <Select> options.
interface AssetCategoryRecord {
id: string;
name: string;
value_group: "high_value" | "low_value";
created_at: string;
}
// Fixed 4-value set — mirrors src/lib/asset-category-attribute-defs.ts's
// dataType z.enum() literal values exactly.
const DATA_TYPE_OPTIONS = ["string", "number", "boolean", "date"] as const;
export default function AssetCategoryAttributeDefsPage() {
const {
data: attributeDefs,
loading,
error,
refetch,
} = useApiList<AssetCategoryAttributeDefRecord>("/api/asset-category-attribute-defs");
const { data: assetCategories } = useApiList<AssetCategoryRecord>("/api/asset-categories");
const [categoryId, setCategoryId] = useState("");
const [key, setKey] = useState("");
const [label, setLabel] = useState("");
const [dataType, setDataType] = useState<(typeof DATA_TYPE_OPTIONS)[number]>("string");
const [required, setRequired] = useState(false);
const [sortOrder, setSortOrder] = useState("0");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
try {
const response = await fetch("/api/asset-category-attribute-defs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
categoryId,
key,
label,
dataType,
required,
sortOrder: Number(sortOrder),
}),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setSubmitError(message);
return;
}
setCategoryId("");
setKey("");
setLabel("");
setDataType("string");
setRequired(false);
setSortOrder("0");
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Asset Category Attribute Defs</h1>
<section className="flex flex-col gap-3">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Category ID</TableHead>
<TableHead>Key</TableHead>
<TableHead>Label</TableHead>
<TableHead>Data Type</TableHead>
<TableHead>Required</TableHead>
<TableHead>Sort Order</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{attributeDefs.map((attributeDef) => (
<TableRow key={attributeDef.id}>
<TableCell>{attributeDef.id}</TableCell>
<TableCell>{attributeDef.category_id}</TableCell>
<TableCell>{attributeDef.key}</TableCell>
<TableCell>{attributeDef.label}</TableCell>
<TableCell>{attributeDef.data_type}</TableCell>
<TableCell>{attributeDef.required ? "Yes" : "No"}</TableCell>
<TableCell>{attributeDef.sort_order}</TableCell>
<TableCell>{attributeDef.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Asset Category Attribute Def</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="categoryId">Category</Label>
<Select value={categoryId} onValueChange={setCategoryId}>
<SelectTrigger id="categoryId" className="w-full">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{assetCategories.map((assetCategory) => (
<SelectItem key={assetCategory.id} value={assetCategory.id}>
{assetCategory.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="key">Key</Label>
<Input
id="key"
value={key}
onChange={(event) => setKey(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="label">Label</Label>
<Input
id="label"
value={label}
onChange={(event) => setLabel(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="dataType">Data Type</Label>
<Select
value={dataType}
onValueChange={(value) =>
setDataType(value as (typeof DATA_TYPE_OPTIONS)[number])
}
>
<SelectTrigger id="dataType" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_TYPE_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<input
id="required"
type="checkbox"
checked={required}
onChange={(event) => setRequired(event.target.checked)}
className="h-4 w-4"
/>
<Label htmlFor="required">Required</Label>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="sortOrder">Sort Order</Label>
<Input
id="sortOrder"
type="number"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value)}
required
/>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Attribute Def"}
</Button>
</form>
</section>
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
"use client";
import { useState, type FormEvent } from "react";
import { useApiList } from "@/hooks/use-api-list";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Shape returned by GET /api/asset-models — snake_case per this repo's
// API-boundary convention (see src/lib/asset-models.ts's toRecord()).
interface AssetModelRecord {
id: string;
category_id: string;
manufacturer: string;
name: string;
specs: Record<string, unknown>;
created_at: string;
}
// Shape returned by GET /api/asset-categories — used only to populate the
// categoryId <Select> here, not rendered as its own table. Deliberately NOT
// filtered to valueGroup=high_value client-side: the server
// (createAssetModel() in src/lib/asset-models.ts) enforces that rule and
// returns a clear {error} message if violated, which this page surfaces
// verbatim via submitError instead of duplicating the rule client-side.
interface AssetCategoryRecord {
id: string;
name: string;
value_group: "high_value" | "low_value";
created_at: string;
}
const DEFAULT_SPECS_TEXT = "{}";
export default function AssetModelsPage() {
const {
data: assetModels,
loading,
error,
refetch,
} = useApiList<AssetModelRecord>("/api/asset-models");
const { data: assetCategories } = useApiList<AssetCategoryRecord>(
"/api/asset-categories"
);
const [categoryId, setCategoryId] = useState("");
const [manufacturer, setManufacturer] = useState("");
const [name, setName] = useState("");
const [specsText, setSpecsText] = useState(DEFAULT_SPECS_TEXT);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
// specs is parsed client-side first — if it's not valid JSON, fail
// locally without calling the API at all (per task scope).
let specs: unknown;
try {
specs = JSON.parse(specsText);
} catch {
setSubmitError("Invalid JSON");
setSubmitting(false);
return;
}
try {
const response = await fetch("/api/asset-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ categoryId, manufacturer, name, specs }),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setSubmitError(message);
return;
}
setCategoryId("");
setManufacturer("");
setName("");
setSpecsText(DEFAULT_SPECS_TEXT);
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Asset Models</h1>
<section className="flex flex-col gap-3">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Category ID</TableHead>
<TableHead>Manufacturer</TableHead>
<TableHead>Name</TableHead>
<TableHead>Specs</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assetModels.map((assetModel) => (
<TableRow key={assetModel.id}>
<TableCell>{assetModel.id}</TableCell>
<TableCell>{assetModel.category_id}</TableCell>
<TableCell>{assetModel.manufacturer}</TableCell>
<TableCell>{assetModel.name}</TableCell>
<TableCell>{JSON.stringify(assetModel.specs)}</TableCell>
<TableCell>{assetModel.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Asset Model</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="categoryId">Category</Label>
<Select value={categoryId} onValueChange={setCategoryId}>
<SelectTrigger id="categoryId" className="w-full">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{assetCategories.map((assetCategory) => (
<SelectItem key={assetCategory.id} value={assetCategory.id}>
{assetCategory.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="manufacturer">Manufacturer</Label>
<Input
id="manufacturer"
value={manufacturer}
onChange={(event) => setManufacturer(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="specs">Specs (JSON)</Label>
<Textarea
id="specs"
value={specsText}
onChange={(event) => setSpecsText(event.target.value)}
rows={6}
/>
</div>
{submitError ? (
<p className="text-sm text-destructive">{submitError}</p>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Asset Model"}
</Button>
</form>
</section>
</div>
);
}
+254
View File
@@ -0,0 +1,254 @@
"use client";
import { useState, type FormEvent } from "react";
import { useApiList } from "@/hooks/use-api-list";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Shape returned by GET /api/assets — snake_case per this repo's
// API-boundary convention (see src/lib/assets.ts's toRecord()).
interface AssetItemRecord {
id: string;
serial_number: string;
model_id: string;
purchase_date: string;
warranty_end_date: string | null;
status: "in_stock" | "assigned" | "repair" | "retired";
purchase_order_id: string | null;
created_at: string;
}
// Shape returned by GET /api/asset-models — only the fields used to populate
// the modelId <Select> here (see src/app/asset-models/page.tsx's
// AssetModelRecord for the full shape).
interface AssetModelRecord {
id: string;
manufacturer: string;
name: string;
}
// Shape returned by GET /api/purchase-orders — only the id, used to populate
// the purchaseOrderId <Select> here. PurchaseOrder has no obviously
// human-readable name field (see src/app/purchase-orders/page.tsx's
// PurchaseOrderRecord for the full shape), so the id itself is shown as the
// label.
interface PurchaseOrderRecord {
id: string;
}
// Radix <Select.Item> can't take an empty-string value, so "no purchase
// order" is represented by this sentinel in the form state and translated to
// "omit the key" on submit — same pattern as purchase-orders page's
// NOT_RECEIVED_VALUE / employees page's NO_MANAGER_VALUE.
const NO_PURCHASE_ORDER_VALUE = "none";
export default function AssetsPage() {
const {
data: assetItems,
loading,
error,
refetch,
} = useApiList<AssetItemRecord>("/api/assets");
const { data: assetModels } = useApiList<AssetModelRecord>("/api/asset-models");
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
const [serialNumber, setSerialNumber] = useState("");
const [modelId, setModelId] = useState("");
const [purchaseDate, setPurchaseDate] = useState("");
const [warrantyEndDate, setWarrantyEndDate] = useState("");
const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
const hasWarrantyEndDate = warrantyEndDate.trim() !== "";
const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE;
try {
const response = await fetch("/api/assets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
serialNumber,
modelId,
purchaseDate,
// Omit optional keys entirely when left blank, rather than
// sending null — matches the create-form pattern used on other
// pages (e.g. purchase-orders page's receivedAt/receivedById).
...(hasWarrantyEndDate ? { warrantyEndDate } : {}),
...(hasPurchaseOrderId ? { purchaseOrderId } : {}),
}),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setSubmitError(message);
return;
}
setSerialNumber("");
setModelId("");
setPurchaseDate("");
setWarrantyEndDate("");
setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE);
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Assets</h1>
<section className="flex flex-col gap-3">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Serial Number</TableHead>
<TableHead>Model ID</TableHead>
<TableHead>Purchase Date</TableHead>
<TableHead>Warranty End Date</TableHead>
<TableHead>Status</TableHead>
<TableHead>Purchase Order ID</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assetItems.map((assetItem) => (
<TableRow key={assetItem.id}>
<TableCell>{assetItem.id}</TableCell>
<TableCell>{assetItem.serial_number}</TableCell>
<TableCell>{assetItem.model_id}</TableCell>
<TableCell>{assetItem.purchase_date}</TableCell>
<TableCell>{assetItem.warranty_end_date ?? "-"}</TableCell>
<TableCell>{assetItem.status}</TableCell>
<TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell>
<TableCell>{assetItem.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Asset</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="serialNumber">Serial Number</Label>
<Input
id="serialNumber"
value={serialNumber}
onChange={(event) => setSerialNumber(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="modelId">Model</Label>
<Select value={modelId} onValueChange={setModelId} required>
<SelectTrigger id="modelId" className="w-full">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{assetModels.map((assetModel) => (
<SelectItem key={assetModel.id} value={assetModel.id}>
{assetModel.manufacturer} {assetModel.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="purchaseDate">Purchase Date</Label>
<Input
id="purchaseDate"
type="date"
value={purchaseDate}
onChange={(event) => setPurchaseDate(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="warrantyEndDate">Warranty End Date (optional)</Label>
<Input
id="warrantyEndDate"
type="date"
value={warrantyEndDate}
onChange={(event) => setWarrantyEndDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="purchaseOrderId">Purchase Order (optional)</Label>
<Select value={purchaseOrderId} onValueChange={setPurchaseOrderId}>
<SelectTrigger id="purchaseOrderId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PURCHASE_ORDER_VALUE}>None</SelectItem>
{purchaseOrders.map((purchaseOrder) => (
<SelectItem key={purchaseOrder.id} value={purchaseOrder.id}>
{purchaseOrder.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Asset"}
</Button>
</form>
</section>
</div>
);
}
+329
View File
@@ -0,0 +1,329 @@
"use client";
import { useState, type FormEvent } from "react";
import { useApiList } from "@/hooks/use-api-list";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Shape returned by GET /api/assignments — snake_case per this repo's
// API-boundary convention (see src/lib/assignments.ts's toRecord()).
interface AssignmentRecord {
id: string;
employee_id: string;
asset_item_id: string | null;
license_id: string | null;
assigned_at: string;
returned_at: string | null;
signed_at: string | null;
created_at: string;
}
// Shape returned by GET /api/employees — only the field used to populate the
// employeeId <Select> here (see src/app/employees/page.tsx's EmployeeRecord
// for the full shape).
interface EmployeeRecord {
id: string;
name: string;
}
// Shape returned by GET /api/assets — only the field used to populate the
// assetItemId <Select> here (see src/app/assets/page.tsx's AssetItemRecord
// for the full shape).
interface AssetItemRecord {
id: string;
serial_number: string;
}
// Shape returned by GET /api/licenses — only the field used to populate the
// licenseId <Select> here (see src/app/licenses/page.tsx's LicenseRecord for
// the full shape).
interface LicenseRecord {
id: string;
name: string;
}
// The create form's XOR toggle — mirrors the server's own XOR rule in
// src/lib/assignments.ts's createAssignmentSchema .refine() (exactly one of
// assetItemId/licenseId must be set). This is purely a UI convenience to
// decide which single <Select> to show/submit — the actual business rule is
// still enforced server-side, this doesn't reimplement it.
type TargetKind = "asset_item" | "license";
export default function AssignmentsPage() {
const {
data: assignments,
loading,
error,
refetch,
} = useApiList<AssignmentRecord>("/api/assignments");
const { data: employees } = useApiList<EmployeeRecord>("/api/employees");
const { data: assetItems } = useApiList<AssetItemRecord>("/api/assets");
const { data: licenses } = useApiList<LicenseRecord>("/api/licenses");
const [employeeId, setEmployeeId] = useState("");
const [targetKind, setTargetKind] = useState<TargetKind>("asset_item");
const [assetItemId, setAssetItemId] = useState("");
const [licenseId, setLicenseId] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// Per-row error surfaced next to the "Return" button that was clicked, keyed
// by assignment id — kept simple (no modal) per task scope.
const [returnErrors, setReturnErrors] = useState<Record<string, string>>({});
const [returningId, setReturningId] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
setSubmitting(true);
try {
const response = await fetch("/api/assignments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
employeeId,
// Only the one FK relevant to the selected toggle is sent — mirrors
// the server's XOR rule (see createAssignmentSchema's .refine()).
...(targetKind === "asset_item" ? { assetItemId } : { licenseId }),
}),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setSubmitError(message);
return;
}
setEmployeeId("");
setTargetKind("asset_item");
setAssetItemId("");
setLicenseId("");
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
async function handleReturn(id: string) {
setReturningId(id);
setReturnErrors((previous) => {
const next = { ...previous };
delete next[id];
return next;
});
try {
// PATCH /api/assignments/[id]'s body is `{returnedAt?: string}` — see
// src/lib/assignments.ts's validateReturnAssignmentInput(). returnedAt
// is optional and defaults to "now" server-side (returnAssignment()),
// so an empty body is sent here rather than guessing a timestamp
// client-side.
const response = await fetch(`/api/assignments/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setReturnErrors((previous) => ({ ...previous, [id]: message }));
return;
}
refetch();
} catch {
setReturnErrors((previous) => ({ ...previous, [id]: "Request failed" }));
} finally {
setReturningId(null);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Assignments</h1>
<section className="flex flex-col gap-3">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Employee ID</TableHead>
<TableHead>Asset Item ID</TableHead>
<TableHead>License ID</TableHead>
<TableHead>Assigned At</TableHead>
<TableHead>Returned At</TableHead>
<TableHead>Signed At</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((assignment) => (
<TableRow key={assignment.id}>
<TableCell>{assignment.id}</TableCell>
<TableCell>{assignment.employee_id}</TableCell>
<TableCell>{assignment.asset_item_id ?? "-"}</TableCell>
<TableCell>{assignment.license_id ?? "-"}</TableCell>
<TableCell>{assignment.assigned_at}</TableCell>
<TableCell>{assignment.returned_at ?? "-"}</TableCell>
<TableCell>{assignment.signed_at ?? "-"}</TableCell>
<TableCell>{assignment.created_at}</TableCell>
<TableCell>
{assignment.returned_at === null ? (
<div className="flex flex-col gap-1">
<Button
type="button"
size="sm"
variant="outline"
disabled={returningId === assignment.id}
onClick={() => handleReturn(assignment.id)}
>
{returningId === assignment.id ? "Returning..." : "Return"}
</Button>
{returnErrors[assignment.id] ? (
<p className="text-xs text-destructive">
{returnErrors[assignment.id]}
</p>
) : null}
</div>
) : (
"-"
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Assignment</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="employeeId">Employee</Label>
<Select value={employeeId} onValueChange={setEmployeeId} required>
<SelectTrigger id="employeeId" className="w-full">
<SelectValue placeholder="Select an employee" />
</SelectTrigger>
<SelectContent>
{employees.map((employee) => (
<SelectItem key={employee.id} value={employee.id}>
{employee.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="targetKind">Assign</Label>
<Select
value={targetKind}
onValueChange={(value) => setTargetKind(value as TargetKind)}
>
<SelectTrigger id="targetKind" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="asset_item">Asset Item</SelectItem>
<SelectItem value="license">License</SelectItem>
</SelectContent>
</Select>
</div>
{targetKind === "asset_item" ? (
<div className="flex flex-col gap-2">
<Label htmlFor="assetItemId">Asset Item</Label>
<Select value={assetItemId} onValueChange={setAssetItemId} required>
<SelectTrigger id="assetItemId" className="w-full">
<SelectValue placeholder="Select an asset item" />
</SelectTrigger>
<SelectContent>
{assetItems.map((assetItem) => (
<SelectItem key={assetItem.id} value={assetItem.id}>
{assetItem.serial_number}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="flex flex-col gap-2">
<Label htmlFor="licenseId">License</Label>
<Select value={licenseId} onValueChange={setLicenseId} required>
<SelectTrigger id="licenseId" className="w-full">
<SelectValue placeholder="Select a license" />
</SelectTrigger>
<SelectContent>
{licenses.map((license) => (
<SelectItem key={license.id} value={license.id}>
{license.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Assignment"}
</Button>
</form>
</section>
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
"use client";
import { useState, type FormEvent } from "react";
import { useApiList } from "@/hooks/use-api-list";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Shape returned by GET /api/employees — snake_case per this repo's
// API-boundary convention (see src/lib/employees.ts's toRecord()).
interface EmployeeRecord {
id: string;
name: string;
department: string;
manager_id: string | null;
created_at: string;
}
// Radix <Select.Item> can't take an empty-string value, so "no manager" is
// represented by this sentinel in the form state and translated to
// `undefined` (omitted from the request body) on submit.
const NO_MANAGER_VALUE = "none";
export default function EmployeesPage() {
const { data: employees, loading, error, refetch } = useApiList<EmployeeRecord>(
"/api/employees"
);
const [name, setName] = useState("");
const [department, setDepartment] = useState("");
const [managerId, setManagerId] = useState(NO_MANAGER_VALUE);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
try {
const response = await fetch("/api/employees", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
department,
...(managerId !== NO_MANAGER_VALUE ? { managerId } : {}),
}),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setSubmitError(message);
return;
}
setName("");
setDepartment("");
setManagerId(NO_MANAGER_VALUE);
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Employees</h1>
<section className="flex flex-col gap-3">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Department</TableHead>
<TableHead>Manager ID</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{employees.map((employee) => (
<TableRow key={employee.id}>
<TableCell>{employee.id}</TableCell>
<TableCell>{employee.name}</TableCell>
<TableCell>{employee.department}</TableCell>
<TableCell>{employee.manager_id ?? "-"}</TableCell>
<TableCell>{employee.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Employee</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="department">Department</Label>
<Input
id="department"
value={department}
onChange={(event) => setDepartment(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="managerId">Manager</Label>
<Select value={managerId} onValueChange={setManagerId}>
<SelectTrigger id="managerId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_MANAGER_VALUE}>None</SelectItem>
{employees.map((employee) => (
<SelectItem key={employee.id} value={employee.id}>
{employee.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? (
<p className="text-sm text-destructive">{submitError}</p>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Employee"}
</Button>
</form>
</section>
</div>
);
}
+14 -2
View File
@@ -1,15 +1,27 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import "./globals.css"; import "./globals.css";
import { auth } from "@/auth";
import { Nav } from "@/components/nav";
export const metadata = { export const metadata = {
title: "Asset & License Management", title: "Asset & License Management",
description: "Internal IT asset & license management — Ubisoft VN local IT", description: "Internal IT asset & license management — Ubisoft VN local IT",
}; };
export default function RootLayout({ children }: { children: ReactNode }) { // Root layout wraps every route, including /login (middleware.ts can't
// exclude it — it's the one page unauthenticated users must be able to
// reach). Gate <Nav/> on session presence here rather than rendering it
// unconditionally, so it only shows on authenticated pages as intended,
// without needing a client-side pathname check.
export default async function RootLayout({ children }: { children: ReactNode }) {
const session = await auth();
return ( return (
<html lang="en"> <html lang="en">
<body>{children}</body> <body>
{session ? <Nav /> : null}
{children}
</body>
</html> </html>
); );
} }
+270
View File
@@ -0,0 +1,270 @@
"use client";
import { useState, type FormEvent } from "react";
import { useApiList } from "@/hooks/use-api-list";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Shape returned by GET /api/licenses — snake_case per this repo's
// API-boundary convention (see src/lib/licenses.ts's toRecord()).
interface LicenseRecord {
id: string;
name: string;
vendor_id: string;
seats_total: number;
seats_used: number;
expiry_date: string | null;
license_type: "perpetual" | "subscription";
purchase_order_id: string | null;
created_at: string;
}
// Shape returned by GET /api/vendors — only the fields used to populate the
// vendorId <Select> (see src/app/vendors/page.tsx's VendorRecord for the
// full shape).
interface VendorRecord {
id: string;
name: string;
}
// Shape returned by GET /api/purchase-orders — only the field used to
// populate the purchaseOrderId <Select> (id as label — a PurchaseOrder has
// no human-friendly name field, see src/app/purchase-orders/page.tsx).
interface PurchaseOrderRecord {
id: string;
}
// Radix <Select.Item> can't take an empty-string value, so "no purchase
// order" is represented by this sentinel in the form state and translated
// to "omit the key" on submit — same pattern as purchase-orders page's
// NOT_RECEIVED_VALUE / employees page's NO_MANAGER_VALUE.
const NO_PURCHASE_ORDER_VALUE = "none";
export default function LicensesPage() {
const {
data: licenses,
loading,
error,
refetch,
} = useApiList<LicenseRecord>("/api/licenses");
const { data: vendors } = useApiList<VendorRecord>("/api/vendors");
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>(
"/api/purchase-orders"
);
const [name, setName] = useState("");
const [vendorId, setVendorId] = useState("");
const [seatsTotal, setSeatsTotal] = useState("");
const [expiryDate, setExpiryDate] = useState("");
const [licenseType, setLicenseType] = useState("");
const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
setSubmitting(true);
try {
const response = await fetch("/api/licenses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
vendorId,
seatsTotal: Number(seatsTotal),
licenseType,
// Omit optional keys entirely when left blank, rather than
// sending null — matches the create-form pattern used on other
// pages (e.g. purchase-orders page's receivedAt/receivedById).
...(expiryDate.trim() !== "" ? { expiryDate } : {}),
...(purchaseOrderId !== NO_PURCHASE_ORDER_VALUE ? { purchaseOrderId } : {}),
}),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setSubmitError(message);
return;
}
setName("");
setVendorId("");
setSeatsTotal("");
setExpiryDate("");
setLicenseType("");
setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE);
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Licenses</h1>
<section className="flex flex-col gap-3">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Vendor ID</TableHead>
<TableHead>Seats Total</TableHead>
<TableHead>Seats Used</TableHead>
<TableHead>Expiry Date</TableHead>
<TableHead>License Type</TableHead>
<TableHead>Purchase Order ID</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{licenses.map((license) => (
<TableRow key={license.id}>
<TableCell>{license.id}</TableCell>
<TableCell>{license.name}</TableCell>
<TableCell>{license.vendor_id}</TableCell>
<TableCell>{license.seats_total}</TableCell>
<TableCell>{license.seats_used}</TableCell>
<TableCell>{license.expiry_date ?? "-"}</TableCell>
<TableCell>{license.license_type}</TableCell>
<TableCell>{license.purchase_order_id ?? "-"}</TableCell>
<TableCell>{license.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New License</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="vendorId">Vendor</Label>
<Select value={vendorId} onValueChange={setVendorId} required>
<SelectTrigger id="vendorId" className="w-full">
<SelectValue placeholder="Select a vendor" />
</SelectTrigger>
<SelectContent>
{vendors.map((vendor) => (
<SelectItem key={vendor.id} value={vendor.id}>
{vendor.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="seatsTotal">Seats Total</Label>
<Input
id="seatsTotal"
type="number"
min={1}
step={1}
value={seatsTotal}
onChange={(event) => setSeatsTotal(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="expiryDate">Expiry Date (optional)</Label>
<Input
id="expiryDate"
type="date"
value={expiryDate}
onChange={(event) => setExpiryDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="licenseType">License Type</Label>
<Select value={licenseType} onValueChange={setLicenseType} required>
<SelectTrigger id="licenseType" className="w-full">
<SelectValue placeholder="Select a license type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="perpetual">perpetual</SelectItem>
<SelectItem value="subscription">subscription</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="purchaseOrderId">Purchase Order (optional)</Label>
<Select value={purchaseOrderId} onValueChange={setPurchaseOrderId}>
<SelectTrigger id="purchaseOrderId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PURCHASE_ORDER_VALUE}>None</SelectItem>
{purchaseOrders.map((purchaseOrder) => (
<SelectItem key={purchaseOrder.id} value={purchaseOrder.id}>
{purchaseOrder.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create License"}
</Button>
</form>
</section>
</div>
);
}
+258
View File
@@ -0,0 +1,258 @@
"use client";
import { useState, type FormEvent } from "react";
import { useApiList } from "@/hooks/use-api-list";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Shape returned by GET /api/purchase-orders — snake_case per this repo's
// API-boundary convention (see src/lib/purchase-orders.ts's toRecord()).
interface PurchaseOrderRecord {
id: string;
vendor_id: string;
order_date: string;
expected_date: string;
received_at: string | null;
received_by: string | null;
created_at: string;
}
// Shape returned by GET /api/vendors — only the fields used to populate the
// vendorId <Select> (see src/app/vendors/page.tsx's VendorRecord for the
// full shape).
interface VendorRecord {
id: string;
name: string;
}
// Shape returned by GET /api/employees — only the fields used to populate
// the receivedById <Select> (see src/app/employees/page.tsx's
// EmployeeRecord for the full shape).
interface EmployeeRecord {
id: string;
name: string;
}
// Radix <Select.Item> can't take an empty-string value, so "not received
// yet" is represented by this sentinel in the form state and translated to
// "omit the key" on submit — same pattern as employees page's
// NO_MANAGER_VALUE.
const NOT_RECEIVED_VALUE = "none";
export default function PurchaseOrdersPage() {
const {
data: purchaseOrders,
loading,
error,
refetch,
} = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
const { data: vendors } = useApiList<VendorRecord>("/api/vendors");
const { data: employees } = useApiList<EmployeeRecord>("/api/employees");
const [vendorId, setVendorId] = useState("");
const [orderDate, setOrderDate] = useState("");
const [expectedDate, setExpectedDate] = useState("");
const [receivedAt, setReceivedAt] = useState("");
const [receivedById, setReceivedById] = useState(NOT_RECEIVED_VALUE);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
// Mirror the server's .refine(): receivedAt/receivedById must both be
// empty or both be filled (src/lib/purchase-orders.ts's
// createPurchaseOrderSchema). Server is still the source of truth if
// this check somehow disagrees with it.
const hasReceivedAt = receivedAt.trim() !== "";
const hasReceivedById = receivedById !== NOT_RECEIVED_VALUE;
if (hasReceivedAt !== hasReceivedById) {
setSubmitError("receivedAt and receivedById must both be set or both be unset");
return;
}
setSubmitting(true);
try {
const response = await fetch("/api/purchase-orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
vendorId,
orderDate,
expectedDate,
// Omit both keys entirely when left empty, rather than sending
// null — matches the create-form pattern used on other pages
// (e.g. employees page's managerId).
...(hasReceivedAt ? { receivedAt } : {}),
...(hasReceivedById ? { receivedById } : {}),
}),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setSubmitError(message);
return;
}
setVendorId("");
setOrderDate("");
setExpectedDate("");
setReceivedAt("");
setReceivedById(NOT_RECEIVED_VALUE);
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Purchase Orders</h1>
<section className="flex flex-col gap-3">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Vendor ID</TableHead>
<TableHead>Order Date</TableHead>
<TableHead>Expected Date</TableHead>
<TableHead>Received At</TableHead>
<TableHead>Received By</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchaseOrders.map((purchaseOrder) => (
<TableRow key={purchaseOrder.id}>
<TableCell>{purchaseOrder.id}</TableCell>
<TableCell>{purchaseOrder.vendor_id}</TableCell>
<TableCell>{purchaseOrder.order_date}</TableCell>
<TableCell>{purchaseOrder.expected_date}</TableCell>
<TableCell>{purchaseOrder.received_at ?? "-"}</TableCell>
<TableCell>{purchaseOrder.received_by ?? "-"}</TableCell>
<TableCell>{purchaseOrder.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Purchase Order</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="vendorId">Vendor</Label>
<Select value={vendorId} onValueChange={setVendorId} required>
<SelectTrigger id="vendorId" className="w-full">
<SelectValue placeholder="Select a vendor" />
</SelectTrigger>
<SelectContent>
{vendors.map((vendor) => (
<SelectItem key={vendor.id} value={vendor.id}>
{vendor.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="orderDate">Order Date</Label>
<Input
id="orderDate"
type="date"
value={orderDate}
onChange={(event) => setOrderDate(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="expectedDate">Expected Date</Label>
<Input
id="expectedDate"
type="date"
value={expectedDate}
onChange={(event) => setExpectedDate(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="receivedAt">Received At (optional)</Label>
<Input
id="receivedAt"
type="date"
value={receivedAt}
onChange={(event) => setReceivedAt(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="receivedById">Received By (optional)</Label>
<Select value={receivedById} onValueChange={setReceivedById}>
<SelectTrigger id="receivedById" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NOT_RECEIVED_VALUE}>Not received</SelectItem>
{employees.map((employee) => (
<SelectItem key={employee.id} value={employee.id}>
{employee.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Purchase Order"}
</Button>
</form>
</section>
</div>
);
}
+251
View File
@@ -0,0 +1,251 @@
"use client";
import { useState, type FormEvent } from "react";
import { useApiList } from "@/hooks/use-api-list";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Shape returned by GET /api/requests — snake_case per this repo's
// API-boundary convention (see src/lib/requests.ts's toRecord()). `status`
// is always "Draft" right now (no state-machine transitions built yet), see
// src/lib/requests.ts's RequestStatus comment.
interface RequestRecord {
id: string;
employee_id: string;
type: "asset" | "license";
reason: string;
status: string;
purchase_order_id: string | null;
created_at: string;
}
// Shape returned by GET /api/employees — only the fields used to populate
// the employeeId <Select> (see src/app/employees/page.tsx's EmployeeRecord
// for the full shape).
interface EmployeeRecord {
id: string;
name: string;
}
// Shape returned by GET /api/purchase-orders — only the id, shown as the
// <Select> label since PurchaseOrder has no human-friendly name field (see
// src/app/purchase-orders/page.tsx's PurchaseOrderRecord for the full
// shape).
interface PurchaseOrderRecord {
id: string;
}
// The exact enum literal values from RequestType (src/lib/requests.ts) —
// don't invent labels not accepted by the Zod schema.
const REQUEST_TYPES = ["asset", "license"] as const;
// Radix <Select.Item> can't take an empty-string value, so "no purchase
// order" is represented by this sentinel in the form state and translated
// to "omit the key" on submit — same pattern as employees page's
// NO_MANAGER_VALUE / purchase-orders page's NOT_RECEIVED_VALUE.
const NO_PURCHASE_ORDER_VALUE = "none";
export default function RequestsPage() {
const {
data: requests,
loading,
error,
refetch,
} = useApiList<RequestRecord>("/api/requests");
const { data: employees } = useApiList<EmployeeRecord>("/api/employees");
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>(
"/api/purchase-orders"
);
const [employeeId, setEmployeeId] = useState("");
const [type, setType] = useState<(typeof REQUEST_TYPES)[number] | "">("");
const [reason, setReason] = useState("");
const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE;
try {
const response = await fetch("/api/requests", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
employeeId,
type,
reason,
// Omit the key entirely when left unset, rather than sending
// null — matches the create-form pattern used on other pages
// (e.g. employees page's managerId).
...(hasPurchaseOrderId ? { purchaseOrderId } : {}),
}),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setSubmitError(message);
return;
}
setEmployeeId("");
setType("");
setReason("");
setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE);
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Requests</h1>
<section className="flex flex-col gap-3">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Employee ID</TableHead>
<TableHead>Type</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Status</TableHead>
<TableHead>Purchase Order ID</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requests.map((request) => (
<TableRow key={request.id}>
<TableCell>{request.id}</TableCell>
<TableCell>{request.employee_id}</TableCell>
<TableCell>{request.type}</TableCell>
<TableCell>{request.reason}</TableCell>
<TableCell>{request.status}</TableCell>
<TableCell>{request.purchase_order_id ?? "-"}</TableCell>
<TableCell>{request.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Request</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="employeeId">Employee</Label>
<Select value={employeeId} onValueChange={setEmployeeId} required>
<SelectTrigger id="employeeId" className="w-full">
<SelectValue placeholder="Select an employee" />
</SelectTrigger>
<SelectContent>
{employees.map((employee) => (
<SelectItem key={employee.id} value={employee.id}>
{employee.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="type">Type</Label>
<Select
value={type}
onValueChange={(value) => setType(value as (typeof REQUEST_TYPES)[number])}
required
>
<SelectTrigger id="type" className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
{REQUEST_TYPES.map((requestType) => (
<SelectItem key={requestType} value={requestType}>
{requestType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="reason">Reason</Label>
<Textarea
id="reason"
value={reason}
onChange={(event) => setReason(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="purchaseOrderId">Purchase Order (optional)</Label>
<Select value={purchaseOrderId} onValueChange={setPurchaseOrderId}>
<SelectTrigger id="purchaseOrderId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PURCHASE_ORDER_VALUE}>None</SelectItem>
{purchaseOrders.map((purchaseOrder) => (
<SelectItem key={purchaseOrder.id} value={purchaseOrder.id}>
{purchaseOrder.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Request"}
</Button>
</form>
</section>
</div>
);
}
+216
View File
@@ -0,0 +1,216 @@
"use client";
import { useState, type FormEvent } from "react";
import { useApiList } from "@/hooks/use-api-list";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Shape returned by GET /api/vendors — snake_case per this repo's
// API-boundary convention (see src/lib/vendors.ts's toRecord()).
interface VendorRecord {
id: string;
name: string;
contact_email: string;
contact_phone: string;
address: string;
tax_code: string | null;
notes: string | null;
created_at: string;
}
export default function VendorsPage() {
const { data: vendors, loading, error, refetch } = useApiList<VendorRecord>(
"/api/vendors"
);
const [name, setName] = useState("");
const [contactEmail, setContactEmail] = useState("");
const [contactPhone, setContactPhone] = useState("");
const [address, setAddress] = useState("");
const [taxCode, setTaxCode] = useState("");
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
try {
const response = await fetch("/api/vendors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
contactEmail,
contactPhone,
address,
// taxCode/notes are optional+nullable and must be non-empty
// strings if present (src/lib/vendors.ts's createVendorSchema) —
// omit the key entirely when the field is left blank, same
// pattern as employees page's managerId.
...(taxCode.trim() !== "" ? { taxCode } : {}),
...(notes.trim() !== "" ? { notes } : {}),
}),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setSubmitError(message);
return;
}
setName("");
setContactEmail("");
setContactPhone("");
setAddress("");
setTaxCode("");
setNotes("");
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Vendors</h1>
<section className="flex flex-col gap-3">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Contact Email</TableHead>
<TableHead>Contact Phone</TableHead>
<TableHead>Address</TableHead>
<TableHead>Tax Code</TableHead>
<TableHead>Notes</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vendors.map((vendor) => (
<TableRow key={vendor.id}>
<TableCell>{vendor.id}</TableCell>
<TableCell>{vendor.name}</TableCell>
<TableCell>{vendor.contact_email}</TableCell>
<TableCell>{vendor.contact_phone}</TableCell>
<TableCell>{vendor.address}</TableCell>
<TableCell>{vendor.tax_code ?? "-"}</TableCell>
<TableCell>{vendor.notes ?? "-"}</TableCell>
<TableCell>{vendor.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Vendor</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="contactEmail">Contact Email</Label>
<Input
id="contactEmail"
type="email"
value={contactEmail}
onChange={(event) => setContactEmail(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="contactPhone">Contact Phone</Label>
<Input
id="contactPhone"
value={contactPhone}
onChange={(event) => setContactPhone(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="address">Address</Label>
<Input
id="address"
value={address}
onChange={(event) => setAddress(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="taxCode">Tax Code</Label>
<Input
id="taxCode"
value={taxCode}
onChange={(event) => setTaxCode(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{submitError ? (
<p className="text-sm text-destructive">{submitError}</p>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Vendor"}
</Button>
</form>
</section>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import Link from "next/link";
import { signOut } from "next-auth/react";
import { Button } from "@/components/ui/button";
// Shared top nav for every authenticated page. Links point at the entity
// routes each later task in this "basic functional UI" batch will build
// (Employee, Vendor, AssetCategory, AssetCategoryAttributeDef, AssetModel,
// PurchaseOrder, AssetItem, License, Request, Assignment) — most of these
// routes don't exist yet and will 404 until their own task lands, which is
// expected (see task scope: no conditional hiding needed).
const ENTITY_LINKS = [
{ href: "/employees", label: "Employees" },
{ href: "/vendors", label: "Vendors" },
{ href: "/asset-categories", label: "Asset Categories" },
{ href: "/asset-category-attribute-defs", label: "Attribute Defs" },
{ href: "/asset-models", label: "Asset Models" },
{ href: "/assets", label: "Assets" },
{ href: "/licenses", label: "Licenses" },
{ href: "/purchase-orders", label: "Purchase Orders" },
{ href: "/requests", label: "Requests" },
{ href: "/assignments", label: "Assignments" },
] as const;
export function Nav() {
return (
<nav className="flex flex-wrap items-center gap-4 border-b border-border px-4 py-3">
<Link href="/" className="font-semibold whitespace-nowrap">
Asset & License Mgmt
</Link>
<div className="flex flex-1 flex-wrap gap-3 text-sm">
{ENTITY_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-muted-foreground hover:text-foreground"
>
{link.label}
</Link>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => signOut({ callbackUrl: "/login" })}
>
Sign out
</Button>
</nav>
);
}
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+190
View File
@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useCallback, useEffect, useState } from "react";
// Small shared data-fetching hook used by every entity page — both for its
// own list table (GET /api/<entity>) and for populating FK <Select>
// dropdowns from other entities' GET endpoints (e.g. an AssetItem page
// fetching /api/employees to build an "assigned to" picker). Deliberately
// minimal: no caching/dedupe/revalidation library, just fetch + state,
// matching the "no polish, just clickable manual testing" scope of this
// task (see CLAUDE.md's "basic functional UI" batch note).
//
// Every API route in this app returns a JSON array on success (200) or
// `{error: string}` on failure (see e.g. src/app/api/employees/route.ts's
// GET/POST) — this hook follows that same shape convention.
type UseApiListResult<T> = {
data: T[];
loading: boolean;
error: string | null;
refetch: () => void;
};
const GENERIC_ERROR_MESSAGE = "Request failed";
export function useApiList<T>(endpoint: string): UseApiListResult<T> {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refetchCount, setRefetchCount] = useState(0);
const refetch = useCallback(() => {
setRefetchCount((count) => count + 1);
}, []);
useEffect(() => {
let cancelled = false;
async function load(): Promise<void> {
setLoading(true);
setError(null);
try {
const response = await fetch(endpoint);
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: GENERIC_ERROR_MESSAGE;
if (!cancelled) {
setData([]);
setError(message);
}
return;
}
if (!cancelled) {
setData(Array.isArray(body) ? (body as T[]) : []);
}
} catch {
if (!cancelled) {
setData([]);
setError(GENERIC_ERROR_MESSAGE);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [endpoint, refetchCount]);
return { data, loading, error, refetch };
}
+107
View File
@@ -0,0 +1,107 @@
import { z } from "zod";
import { prisma } from "./prisma";
export type AssetCategoryValueGroup = "high_value" | "low_value";
export interface AssetCategoryRecord {
id: string;
name: string;
value_group: AssetCategoryValueGroup;
created_at: string;
}
export interface CreateAssetCategoryInput {
name: string;
valueGroup: AssetCategoryValueGroup;
}
export type ValidationResult =
| { ok: true; value: CreateAssetCategoryInput }
| { ok: false; error: string };
// Mirrors src/lib/vendors.ts's nonEmptyString helper.
function nonEmptyString(message: string) {
return z.string({ required_error: message, invalid_type_error: message }).refine(
(value) => value.trim() !== "",
{ message }
);
}
const createAssetCategorySchema = z.object({
name: nonEmptyString("name is required and must be a non-empty string"),
// Fixed 2-value set (see docs/asset-license-management.md's "## Phân
// loại tài sản" section) — mirrors src/lib/licenses.ts's licenseType
// z.enum() pattern.
valueGroup: z.enum(["high_value", "low_value"], {
errorMap: () => ({
message: "valueGroup is required and must be either 'high_value' or 'low_value'",
}),
}),
});
/**
* Validates raw (untyped) asset-category-creation input. name must be a
* non-empty string, valueGroup must be one of the two fixed enum values
* (per the "## Phân loại tài sản" spec in docs/asset-license-management.md).
*/
export function validateCreateAssetCategoryInput(input: {
name?: unknown;
valueGroup?: unknown;
}): ValidationResult {
const result = createAssetCategorySchema.safeParse(input);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid input";
return { ok: false, error: message };
}
return { ok: true, value: result.data };
}
function toRecord(row: {
id: string;
name: string;
valueGroup: AssetCategoryValueGroup;
createdAt: Date;
}): AssetCategoryRecord {
return {
id: row.id,
name: row.name,
value_group: row.valueGroup,
created_at: row.createdAt.toISOString(),
};
}
export type CreateAssetCategoryResult =
| { ok: true; value: AssetCategoryRecord }
| { ok: false; error: string };
/**
* Creates an AssetCategory. No unique constraint on any field, no FKs
* either (see docs/asset-license-management.md's "## Phân loại tài sản"
* spec — standalone entity, same starting shape as Vendor before it grew
* relations) — so there's no known Prisma error code to catch/translate
* here today. Still returns the `{ok,value}|{ok,error}` shape (rather than
* a bare Promise<AssetCategoryRecord> like createVendor()) to match the
* convention used across the newer entities (AssetItem/License) — see the
* code-reviewer finding on createLicense() in CLAUDE.md's "Trạng thái hiện
* tại": adding this shape up front avoids a silent 500 if a unique
* constraint is added later (e.g. on `name`) without remembering to also
* add P2002 handling here.
*/
export async function createAssetCategory(
input: CreateAssetCategoryInput
): Promise<CreateAssetCategoryResult> {
const created = await prisma.assetCategory.create({
data: {
name: input.name,
valueGroup: input.valueGroup,
},
});
return { ok: true, value: toRecord(created) };
}
export async function listAssetCategories(): Promise<AssetCategoryRecord[]> {
const rows = await prisma.assetCategory.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
+169
View File
@@ -0,0 +1,169 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
export type AssetAttributeDataType = "string" | "number" | "boolean" | "date";
export interface AssetCategoryAttributeDefRecord {
id: string;
category_id: string;
key: string;
label: string;
data_type: AssetAttributeDataType;
required: boolean;
sort_order: number;
created_at: string;
}
export interface CreateAssetCategoryAttributeDefInput {
categoryId: string;
key: string;
label: string;
dataType: AssetAttributeDataType;
required: boolean;
sortOrder: number;
}
export type ValidationResult =
| { ok: true; value: CreateAssetCategoryAttributeDefInput }
| { ok: false; error: string };
// Mirrors src/lib/asset-categories.ts's nonEmptyString helper.
function nonEmptyString(message: string) {
return z.string({ required_error: message, invalid_type_error: message }).refine(
(value) => value.trim() !== "",
{ message }
);
}
const createAssetCategoryAttributeDefSchema = z.object({
// FK to AssetCategory — only a UUID *format* check here, whether it
// references a real category is left to Prisma's own FK constraint at
// insert time (see createAssetCategoryAttributeDef()'s P2003 handling
// below). Mirrors src/lib/purchase-orders.ts's vendorId handling.
categoryId: z
.string({ required_error: "categoryId is required and must be a valid UUID string" })
.uuid("categoryId is required and must be a valid UUID string"),
// Slug-style, e.g. "ram_gb" — only unique *within* a category (composite
// unique (categoryId, key) at the DB layer), not globally. Just requires
// non-empty here; the DB-level uniqueness is enforced by
// createAssetCategoryAttributeDef()'s P2002 handling below.
key: nonEmptyString("key is required and must be a non-empty string"),
label: nonEmptyString("label is required and must be a non-empty string"),
// Fixed 4-value set (see docs/asset-license-management.md's "Cập nhật
// 2026-08-02 (3)" section) — a DEV-defined closed technical set, mirrors
// src/lib/asset-categories.ts's valueGroup z.enum() pattern.
dataType: z.enum(["string", "number", "boolean", "date"], {
errorMap: () => ({
message: "dataType is required and must be one of 'string', 'number', 'boolean', 'date'",
}),
}),
required: z.boolean({
required_error: "required is required and must be a boolean",
invalid_type_error: "required is required and must be a boolean",
}),
sortOrder: z.number({
required_error: "sortOrder is required and must be an integer",
invalid_type_error: "sortOrder is required and must be an integer",
})
.int("sortOrder is required and must be an integer"),
});
/**
* Validates raw (untyped) asset-category-attribute-def-creation input.
* categoryId must be a valid UUID string, key/label must be non-empty
* strings, dataType must be one of the four fixed enum values, required
* must be a boolean, sortOrder must be an integer. Whether categoryId
* references a real AssetCategory, and whether (categoryId, key) is
* actually unique, are both left to Prisma's constraints at insert time
* (see createAssetCategoryAttributeDef() below).
*/
export function validateCreateAssetCategoryAttributeDefInput(input: {
categoryId?: unknown;
key?: unknown;
label?: unknown;
dataType?: unknown;
required?: unknown;
sortOrder?: unknown;
}): ValidationResult {
const result = createAssetCategoryAttributeDefSchema.safeParse(input);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid input";
return { ok: false, error: message };
}
return { ok: true, value: result.data };
}
function toRecord(row: {
id: string;
categoryId: string;
key: string;
label: string;
dataType: AssetAttributeDataType;
required: boolean;
sortOrder: number;
createdAt: Date;
}): AssetCategoryAttributeDefRecord {
return {
id: row.id,
category_id: row.categoryId,
key: row.key,
label: row.label,
data_type: row.dataType,
required: row.required,
sort_order: row.sortOrder,
created_at: row.createdAt.toISOString(),
};
}
export type CreateAssetCategoryAttributeDefResult =
| { ok: true; value: AssetCategoryAttributeDefRecord }
| { ok: false; error: string };
/**
* Creates an AssetCategoryAttributeDef. If (categoryId, key) collides with
* an existing row, Prisma raises P2002 (composite unique constraint
* violation) — caught here and translated into a clean {ok:false,error},
* same pattern as createAssetItem()'s serialNumber P2002 handling in
* src/lib/assets.ts. Note this is a *composite* uniqueness (unique within a
* category, not globally) — the same `key` under a *different* categoryId
* is expected to succeed, per docs/asset-license-management.md's "Cập nhật
* 2026-08-02 (3)" section. If categoryId doesn't reference an existing
* AssetCategory, Prisma raises P2003 (FK constraint violation) — caught the
* same way, mirrors src/lib/purchase-orders.ts's createPurchaseOrder() P2003
* handling.
*/
export async function createAssetCategoryAttributeDef(
input: CreateAssetCategoryAttributeDefInput
): Promise<CreateAssetCategoryAttributeDefResult> {
try {
const created = await prisma.assetCategoryAttributeDef.create({
data: {
categoryId: input.categoryId,
key: input.key,
label: input.label,
dataType: input.dataType,
required: input.required,
sortOrder: input.sortOrder,
},
});
return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
return { ok: false, error: "key already exists for this category" };
}
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
return { ok: false, error: "categoryId does not reference an existing asset category" };
}
throw error;
}
}
export async function listAssetCategoryAttributeDefs(): Promise<
AssetCategoryAttributeDefRecord[]
> {
const rows = await prisma.assetCategoryAttributeDef.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
+214
View File
@@ -0,0 +1,214 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
import type { AssetAttributeDataType } from "./asset-category-attribute-defs";
import { prisma } from "./prisma";
export interface AssetModelRecord {
id: string;
category_id: string;
manufacturer: string;
name: string;
specs: Record<string, unknown>;
created_at: string;
}
export interface CreateAssetModelInput {
categoryId: string;
manufacturer: string;
name: string;
specs: Record<string, unknown>;
}
export type ValidationResult =
| { ok: true; value: CreateAssetModelInput }
| { ok: false; error: string };
// Mirrors src/lib/asset-category-attribute-defs.ts's nonEmptyString helper.
function nonEmptyString(message: string) {
return z.string({ required_error: message, invalid_type_error: message }).refine(
(value) => value.trim() !== "",
{ message }
);
}
const createAssetModelSchema = z.object({
// FK to AssetCategory — only a UUID *format* check here, whether it
// references a real category (and whether that category is
// valueGroup=high_value) is left to createAssetModel()'s explicit lookup
// below — that's a business rule, not something pure Zod schema
// validation on this input alone can express. Mirrors
// src/lib/asset-category-attribute-defs.ts's categoryId handling.
categoryId: z
.string({ required_error: "categoryId is required and must be a valid UUID string" })
.uuid("categoryId is required and must be a valid UUID string"),
manufacturer: nonEmptyString("manufacturer is required and must be a non-empty string"),
name: nonEmptyString("name is required and must be a non-empty string"),
// Deliberately LOOSE at this stage — just "a plain JSON object" (no
// arrays, no primitives at the top level). The category-specific dynamic
// shape (which keys are allowed, which are required, and their types) is
// validated separately in createAssetModel() below, via a runtime Zod
// schema built from that category's AssetCategoryAttributeDef rows — it
// can't be done here because that requires a DB lookup keyed on
// categoryId, which this pure-input-shape schema doesn't have access to.
// Defaults to {} when omitted, matching the DB column default.
specs: z
.record(z.unknown(), {
invalid_type_error: "specs must be a JSON object",
})
.optional()
.default({}),
});
/**
* Validates raw (untyped) asset-model-creation input. categoryId must be a
* valid UUID string, manufacturer/name must be non-empty strings, specs
* must be a plain JSON object (or omitted, defaulting to {}). Whether
* categoryId references a real, high_value AssetCategory is checked in
* createAssetModel() below, not here — that requires a DB lookup.
*/
export function validateCreateAssetModelInput(input: {
categoryId?: unknown;
manufacturer?: unknown;
name?: unknown;
specs?: unknown;
}): ValidationResult {
const result = createAssetModelSchema.safeParse(input);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid input";
return { ok: false, error: message };
}
return { ok: true, value: result.data };
}
function toRecord(row: {
id: string;
categoryId: string;
manufacturer: string;
name: string;
specs: Prisma.JsonValue;
createdAt: Date;
}): AssetModelRecord {
return {
id: row.id,
category_id: row.categoryId,
manufacturer: row.manufacturer,
name: row.name,
specs: (row.specs as Record<string, unknown>) ?? {},
created_at: row.createdAt.toISOString(),
};
}
// Maps an AssetCategoryAttributeDef.dataType to the corresponding Zod leaf
// type, per docs/asset-license-management.md's "Cập nhật 2026-08-02 (3)"
// section: string -> z.string(), number -> z.number(), boolean ->
// z.boolean(), date -> z.coerce.date() (coerce so a JSON-transported date
// *string*, e.g. "2027-01-01", is accepted — JSON has no native Date type).
function zodTypeForDataType(dataType: AssetAttributeDataType): z.ZodTypeAny {
switch (dataType) {
case "string":
return z.string();
case "number":
return z.number();
case "boolean":
return z.boolean();
case "date":
return z.coerce.date();
}
}
/**
* Builds a Zod object schema AT RUNTIME from a category's
* AssetCategoryAttributeDef rows: one shape entry per attribute def (typed
* per zodTypeForDataType() above), optional when `required` is false via
* `.optional()`, and `.strict()` on the overall object so any `specs` key
* not defined for this category is rejected (no unknown keys allowed), per
* the doc section referenced above.
*/
function buildSpecsSchema(
attributeDefs: { key: string; dataType: AssetAttributeDataType; required: boolean }[]
): z.ZodTypeAny {
const shape: Record<string, z.ZodTypeAny> = {};
for (const def of attributeDefs) {
const leaf = zodTypeForDataType(def.dataType);
shape[def.key] = def.required ? leaf : leaf.optional();
}
return z.object(shape).strict();
}
export type CreateAssetModelResult =
| { ok: true; value: AssetModelRecord }
| { ok: false; error: string };
/**
* Creates an AssetModel. categoryId must reference a real AssetCategory
* (checked here via an explicit prisma.assetCategory.findUnique() lookup —
* not left to Prisma's P2003 FK-violation catch like other entities'
* simple FK checks, e.g. src/lib/asset-category-attribute-defs.ts's
* categoryId handling), because there's an additional business rule to
* enforce beyond "does this row exist": the referenced category's
* valueGroup must be `high_value` (per docs/asset-license-management.md's
* "Ràng buộc dữ liệu" section under "Cập nhật 2026-08-02 (2)") — a rule
* that can't be expressed as a Postgres FK constraint, so it can't be
* caught via a Prisma error code the way a plain missing-FK case can. If
* the category doesn't exist at all, or exists but is `low_value`, this
* returns a distinct {ok:false,error} for each case rather than a raw
* P2003/500.
*/
export async function createAssetModel(
input: CreateAssetModelInput
): Promise<CreateAssetModelResult> {
const category = await prisma.assetCategory.findUnique({
where: { id: input.categoryId },
});
if (!category) {
return { ok: false, error: "categoryId does not reference an existing asset category" };
}
if (category.valueGroup !== "high_value") {
return { ok: false, error: "categoryId must reference a category with valueGroup=high_value" };
}
// Dynamic specs validation, per docs/asset-license-management.md's
// "Cập nhật 2026-08-02 (3)" section: build a Zod schema at runtime from
// this category's AssetCategoryAttributeDef rows, and validate the
// incoming specs object against it. Deliberately done here (after the
// category existence/high_value checks above), not inside
// validateCreateAssetModelInput()'s static schema, because it needs a DB
// lookup keyed on categoryId.
const attributeDefs = await prisma.assetCategoryAttributeDef.findMany({
where: { categoryId: input.categoryId },
});
const specsSchema = buildSpecsSchema(attributeDefs);
const specsResult = specsSchema.safeParse(input.specs);
if (!specsResult.success) {
const message = specsResult.error.issues[0]?.message ?? "Invalid specs";
const path = specsResult.error.issues[0]?.path.join(".");
return {
ok: false,
error: path ? `specs.${path}: ${message}` : `specs: ${message}`,
};
}
const created = await prisma.assetModel.create({
data: {
categoryId: input.categoryId,
manufacturer: input.manufacturer,
name: input.name,
// input.specs is validated by Zod to be a plain JSON object
// (Record<string, unknown>), but Prisma's generated InputJsonValue
// type doesn't structurally accept an index-signature Record — cast
// is safe here since the Zod schema already guarantees JSON-shaped
// values.
specs: input.specs as Prisma.InputJsonValue,
},
});
return { ok: true, value: toRecord(created) };
}
export async function listAssetModels(): Promise<AssetModelRecord[]> {
const rows = await prisma.assetModel.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
+54 -14
View File
@@ -7,18 +7,20 @@ export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired";
export interface AssetItemRecord { export interface AssetItemRecord {
id: string; id: string;
serial_number: string; serial_number: string;
model: string; model_id: string;
purchase_date: string; purchase_date: string;
warranty_end_date: string | null; warranty_end_date: string | null;
status: AssetItemStatus; status: AssetItemStatus;
purchase_order_id: string | null;
created_at: string; created_at: string;
} }
export interface CreateAssetItemInput { export interface CreateAssetItemInput {
serialNumber: string; serialNumber: string;
model: string; modelId: string;
purchaseDate: Date; purchaseDate: Date;
warrantyEndDate?: Date; warrantyEndDate?: Date;
purchaseOrderId?: string | null;
} }
export type ValidationResult = export type ValidationResult =
@@ -45,24 +47,45 @@ function dateString(message: string) {
const createAssetItemSchema = z.object({ const createAssetItemSchema = z.object({
serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"), serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"),
model: nonEmptyString("model is required and must be a non-empty string"), // Required FK to AssetModel — replaced the free-text `model` String (see
// schema.prisma / docs/asset-license-management.md's "Cập nhật 2026-08-02
// (2)" section). Only a UUID *format* check here, whether it references a
// real AssetModel is left to Prisma's own FK constraint at insert time
// (see createAssetItem()'s P2003 handling below). Mirrors
// src/lib/licenses.ts's vendorId handling.
modelId: z
.string({ required_error: "modelId is required and must be a valid UUID string" })
.uuid("modelId is required and must be a valid UUID string"),
purchaseDate: dateString("purchaseDate is required and must be a valid date string"), purchaseDate: dateString("purchaseDate is required and must be a valid date string"),
warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(), warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(),
// FK to PurchaseOrder — nullable/optional, supply-side traceability (not
// every AssetItem goes through a formal PO). Only a UUID *format* check
// here, whether it references a real PurchaseOrder is left to Prisma's
// own FK constraint at insert time (see createAssetItem()'s P2003
// handling below). Mirrors src/lib/purchase-orders.ts's receivedById
// optional().nullable() pattern.
purchaseOrderId: z
.string({ invalid_type_error: "purchaseOrderId must be a valid UUID string" })
.uuid("purchaseOrderId must be a valid UUID string")
.optional()
.nullable(),
}); });
/** /**
* Validates raw (untyped) asset-item-creation input. serialNumber/model * Validates raw (untyped) asset-item-creation input. serialNumber must be a
* must be non-empty strings, purchaseDate must be a valid date string, * non-empty string, modelId must be a valid UUID string, purchaseDate must
* warrantyEndDate is optional but must be a valid date string if present. * be a valid date string, warrantyEndDate is optional but must be a valid
* status is not accepted here — new assets always start "in_stock" (same * date string if present. purchaseOrderId is optional/nullable but must be
* pattern as Request always starting "Draft"), later states are set via * a valid UUID if present. status is not accepted here — new assets always
* other operations, not at creation. * start "in_stock" (same pattern as Request always starting "Draft"), later
* states are set via other operations, not at creation.
*/ */
export function validateCreateAssetItemInput(input: { export function validateCreateAssetItemInput(input: {
serialNumber?: unknown; serialNumber?: unknown;
model?: unknown; modelId?: unknown;
purchaseDate?: unknown; purchaseDate?: unknown;
warrantyEndDate?: unknown; warrantyEndDate?: unknown;
purchaseOrderId?: unknown;
}): ValidationResult { }): ValidationResult {
const result = createAssetItemSchema.safeParse(input); const result = createAssetItemSchema.safeParse(input);
if (!result.success) { if (!result.success) {
@@ -75,19 +98,21 @@ export function validateCreateAssetItemInput(input: {
function toRecord(row: { function toRecord(row: {
id: string; id: string;
serialNumber: string; serialNumber: string;
model: string; modelId: string;
purchaseDate: Date; purchaseDate: Date;
warrantyEndDate: Date | null; warrantyEndDate: Date | null;
status: AssetItemStatus; status: AssetItemStatus;
purchaseOrderId: string | null;
createdAt: Date; createdAt: Date;
}): AssetItemRecord { }): AssetItemRecord {
return { return {
id: row.id, id: row.id,
serial_number: row.serialNumber, serial_number: row.serialNumber,
model: row.model, model_id: row.modelId,
purchase_date: row.purchaseDate.toISOString(), purchase_date: row.purchaseDate.toISOString(),
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null, warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
status: row.status, status: row.status,
purchase_order_id: row.purchaseOrderId,
created_at: row.createdAt.toISOString(), created_at: row.createdAt.toISOString(),
}; };
} }
@@ -102,7 +127,14 @@ export type CreateAssetItemResult =
* caught here and translated into the same `{ok:false,error}` shape as * caught here and translated into the same `{ok:false,error}` shape as
* input validation failures, rather than letting a raw Prisma error leak * input validation failures, rather than letting a raw Prisma error leak
* to the API layer. Mirrors createEmployee()/createRequest()'s P2003 * to the API layer. Mirrors createEmployee()/createRequest()'s P2003
* handling for FK violations. * handling for FK violations. Two separate FKs can fail here (modelId ->
* AssetModel, purchaseOrderId -> PurchaseOrder) — Prisma raises the same
* error code (P2003) for either, so the FK constraint name in
* `error.meta.field_name` is used to tell them apart and return a targeted
* message, rather than a raw Prisma error leaking to the API layer. Mirrors
* src/lib/licenses.ts's createLicense() / src/lib/purchase-orders.ts's
* createPurchaseOrder() pattern for disambiguating between two FKs on the
* same model.
*/ */
export async function createAssetItem( export async function createAssetItem(
input: CreateAssetItemInput input: CreateAssetItemInput
@@ -111,9 +143,10 @@ export async function createAssetItem(
const created = await prisma.assetItem.create({ const created = await prisma.assetItem.create({
data: { data: {
serialNumber: input.serialNumber, serialNumber: input.serialNumber,
model: input.model, modelId: input.modelId,
purchaseDate: input.purchaseDate, purchaseDate: input.purchaseDate,
warrantyEndDate: input.warrantyEndDate ?? null, warrantyEndDate: input.warrantyEndDate ?? null,
purchaseOrderId: input.purchaseOrderId ?? null,
}, },
}); });
return { ok: true, value: toRecord(created) }; return { ok: true, value: toRecord(created) };
@@ -121,6 +154,13 @@ export async function createAssetItem(
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
return { ok: false, error: "serialNumber already exists" }; return { ok: false, error: "serialNumber already exists" };
} }
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
if (fieldName.includes("purchase_order")) {
return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" };
}
return { ok: false, error: "modelId does not reference an existing asset model" };
}
throw error; throw error;
} }
} }
+533
View File
@@ -0,0 +1,533 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
export interface AssignmentRecord {
id: string;
employee_id: string;
asset_item_id: string | null;
license_id: string | null;
assigned_at: string;
returned_at: string | null;
signed_at: string | null;
created_at: string;
}
// assignedAt/returnedAt/signedAt are all deliberately NOT accepted from the
// client at creation time — same precedent as AssetItem.status always
// starting "in_stock" and License.seatsUsed always starting 0:
// assignedAt is a system-set "moment of assignment" timestamp
// (Prisma's @default(now()) on Assignment.assignedAt), returnedAt must be
// null on creation (an Assignment cannot be created already-returned — that
// would make no sense given the seat/status side-effects below), and
// signedAt (marking handover paperwork as signed) is not wired to any
// endpoint in this task at all — see feature-implementer report's "Out of
// scope" section. It stays null until a future "mark as signed" operation
// is built.
export interface CreateAssignmentInput {
employeeId: string;
assetItemId?: string | null;
licenseId?: string | null;
}
export type ValidationResult =
| { ok: true; value: CreateAssignmentInput }
| { ok: false; error: string };
// Mirrors src/lib/purchase-orders.ts's dateString helper — accepts any
// string Date.parse() can handle and transforms it into a Date for Prisma.
function dateString(message: string) {
return z
.string({ required_error: message, invalid_type_error: message })
.refine((value) => !Number.isNaN(Date.parse(value)), { message })
.transform((value) => new Date(value));
}
// Shared between createAssignmentSchema's employeeId field and
// transferAssignmentSchema below — kept as a standalone Zod schema (rather
// than inlined in both places) so the two stay byte-for-byte in sync on the
// exact error message, instead of two copies drifting apart over time.
const employeeIdSchema = z
.string({ required_error: "employeeId is required and must be a valid UUID string" })
.uuid("employeeId is required and must be a valid UUID string");
const createAssignmentSchema = z
.object({
// FK to Employee — only a UUID *format* check here, whether it
// references a real employee is left to Prisma's own FK constraint at
// insert time (see createAssignment()'s P2003 handling below). Mirrors
// src/lib/requests.ts's employee_id handling.
employeeId: employeeIdSchema,
// Exactly one of assetItemId/licenseId must be set — see the .refine()
// below for the XOR check. Unlike other nullable FKs in this codebase
// (e.g. AssetItem.purchaseOrderId), whether these reference a real row
// is NOT deferred entirely to Prisma's insert-time FK constraint —
// createAssignment() needs to read the referenced row anyway (to check
// AssetItem.status / License.seatsUsed business rules), so existence is
// checked explicitly inside the transaction instead. Only format
// validation happens here at the Zod layer.
assetItemId: z
.string({ invalid_type_error: "assetItemId must be a valid UUID string" })
.uuid("assetItemId must be a valid UUID string")
.optional()
.nullable(),
licenseId: z
.string({ invalid_type_error: "licenseId must be a valid UUID string" })
.uuid("licenseId must be a valid UUID string")
.optional()
.nullable(),
})
.refine(
(value) => {
const hasAssetItem = value.assetItemId !== undefined && value.assetItemId !== null;
const hasLicense = value.licenseId !== undefined && value.licenseId !== null;
// XOR: exactly one of the two must be set.
return hasAssetItem !== hasLicense;
},
{
message: "Exactly one of assetItemId or licenseId must be set (not both, not neither)",
// Attach the error to both fields so it surfaces regardless of which
// one the caller looks at first — mirrors
// src/lib/purchase-orders.ts's receivedAt/receivedById co-nullability
// refine, which attaches to a single path for the same reason.
path: ["assetItemId"],
}
);
/**
* Validates raw (untyped) assignment-creation input. employeeId must be a
* valid UUID string. Exactly one of assetItemId/licenseId must be a valid
* UUID string (XOR — both set or both unset is rejected). assignedAt,
* returnedAt, and signedAt are not accepted here — see CreateAssignmentInput's
* doc comment for why.
*/
export function validateCreateAssignmentInput(input: {
employeeId?: unknown;
assetItemId?: unknown;
licenseId?: unknown;
}): ValidationResult {
const result = createAssignmentSchema.safeParse(input);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid input";
return { ok: false, error: message };
}
return { ok: true, value: result.data };
}
const returnAssignmentSchema = z.object({
// Optional — defaults to "now" at the time returnAssignment() runs if not
// provided (mirrors Assignment.assignedAt's own default-now behavior at
// creation). Accepting an explicit value lets a caller backdate a return
// (e.g. recording a return that was physically handled yesterday but only
// entered into the system today) — same spirit as PurchaseOrder accepting
// an explicit receivedAt rather than always stamping "now".
returnedAt: dateString("returnedAt must be a valid date string").optional(),
});
export type ReturnValidationResult =
| { ok: true; value: { returnedAt?: Date } }
| { ok: false; error: string };
/**
* Validates raw (untyped) assignment-return input. returnedAt is optional
* but must be a valid date string if present.
*/
export function validateReturnAssignmentInput(input: {
returnedAt?: unknown;
}): ReturnValidationResult {
const result = returnAssignmentSchema.safeParse(input);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid input";
return { ok: false, error: message };
}
return { ok: true, value: result.data };
}
const transferAssignmentSchema = z.object({
// Reuses employeeIdSchema (see its doc comment above) — the new employee
// to hand the same AssetItem/License over to. assetItemId/licenseId are
// deliberately NOT accepted here: transferAssignment() reuses whichever
// one the original Assignment already had (see that function's doc
// comment), the caller doesn't get to change it via a transfer.
employeeId: employeeIdSchema,
});
export type TransferValidationResult =
| { ok: true; value: { employeeId: string } }
| { ok: false; error: string };
/**
* Validates raw (untyped) assignment-transfer input. employeeId must be a
* valid UUID string (same format check as validateCreateAssignmentInput's
* employeeId field).
*/
export function validateTransferAssignmentInput(input: {
employeeId?: unknown;
}): TransferValidationResult {
const result = transferAssignmentSchema.safeParse(input);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid input";
return { ok: false, error: message };
}
return { ok: true, value: result.data };
}
function toRecord(row: {
id: string;
employeeId: string;
assetItemId: string | null;
licenseId: string | null;
assignedAt: Date;
returnedAt: Date | null;
signedAt: Date | null;
createdAt: Date;
}): AssignmentRecord {
return {
id: row.id,
employee_id: row.employeeId,
asset_item_id: row.assetItemId,
license_id: row.licenseId,
assigned_at: row.assignedAt.toISOString(),
returned_at: row.returnedAt ? row.returnedAt.toISOString() : null,
signed_at: row.signedAt ? row.signedAt.toISOString() : null,
created_at: row.createdAt.toISOString(),
};
}
// Internal error type used to carry a clean, already-user-facing message out
// of the `prisma.$transaction()` callback below, distinguishing
// application-level business-rule rejections (400) from unexpected/infra
// errors (rethrown as-is). Not exported — callers only ever see the
// {ok,error} result shape.
class AssignmentBusinessError extends Error {
constructor(message: string) {
super(message);
this.name = "AssignmentBusinessError";
}
}
class AssignmentNotFoundError extends Error {
constructor() {
super("Assignment not found");
this.name = "AssignmentNotFoundError";
}
}
export type CreateAssignmentResult =
| { ok: true; value: AssignmentRecord }
| { ok: false; error: string };
/**
* Creates an Assignment (assigns an AssetItem or a License seat to an
* Employee), enforcing every business rule from docs/
* asset-license-management.md's "## Assignment (chốt 2026-08-02)" section
* inside a single `prisma.$transaction()`:
*
* - AssetItem branch: rejects if the AssetItem doesn't exist, rejects if its
* current status isn't "in_stock" (stakeholder-confirmed precondition —
* see the doc's "chốt 2026-08-02 (2)" note), rejects if there's already an
* ACTIVE Assignment (returnedAt IS NULL) for it (explicit
* belt-and-suspenders invariant per the doc, on top of the status check),
* then atomically flips AssetItem.status to "assigned".
* - License branch: rejects if the License doesn't exist, rejects if
* seatsUsed >= seatsTotal (no overbooking), then atomically increments
* seatsUsed by 1.
*
* Race-condition note (read this before touching the transaction body):
* both branches use a two-step "read for validation, then conditional
* `updateMany` with a WHERE guard" pattern instead of a plain read-then-write
* (`findUnique` then `update`). The WHERE-guarded `updateMany` — e.g.
* `updateMany({ where: { id, status: "in_stock" }, data: { status:
* "assigned" } })` — is the actual race-closing mechanism: Postgres
* evaluates and applies that as a single atomic UPDATE statement, taking a
* row lock for its duration. Two concurrent transactions racing on the same
* AssetItem/License row serialize on that lock — the first to commit wins,
* and the second's WHERE clause is re-evaluated against the now-committed
* row and no longer matches, so its `updateMany` returns `count: 0`, which
* this function treats as a "lost the race" rejection. This is why the
* `findUnique` calls right before each `updateMany` are NOT the
* race-closing check by themselves (there's a real TOCTOU gap between a
* `findUnique` and a later `update` in isolation) — they only exist to
* produce a specific, friendly error message (not-found vs. wrong-status vs.
* no-seats) for the *common* (non-racing) case; the `updateMany`'s `count`
* is always re-checked afterwards and is what actually gets trusted.
* seatsTotal itself is read once via that same `findUnique` and reused in
* the `updateMany`'s WHERE clause (`seatsUsed: { lt: seatsTotal }`) — this
* is safe because nothing in this codebase ever mutates seatsTotal after
* creation (no update endpoint exists for it), so it can't go stale between
* the read and the write within the same transaction.
*
* The explicit "no existing active Assignment for this AssetItem" check
* (`tx.assignment.findFirst({ where: { assetItemId, returnedAt: null } })`)
* is intentionally layered BEFORE the atomic status update, as defense in
* depth against AssetItem.status/Assignment-row drift (e.g. manual DB
* intervention) — under normal operation this codebase always keeps
* `status === "assigned"` in exact sync with "has an active Assignment", so
* the atomic status-guarded `updateMany` above is what actually closes the
* concurrency race for the AssetItem branch; this check does not need its
* own atomic guard on top because the transaction's row lock (from the
* subsequent `updateMany`) is what makes the overall operation safe, not
* this read on its own.
*/
export async function createAssignment(
input: CreateAssignmentInput
): Promise<CreateAssignmentResult> {
try {
const created = await prisma.$transaction(async (tx) => {
if (input.assetItemId) {
const assetItem = await tx.assetItem.findUnique({ where: { id: input.assetItemId } });
if (!assetItem) {
throw new AssignmentBusinessError(
"assetItemId does not reference an existing asset item"
);
}
if (assetItem.status !== "in_stock") {
throw new AssignmentBusinessError(
`assetItemId refers to an AssetItem that is not in_stock (current status: ${assetItem.status})`
);
}
const existingActive = await tx.assignment.findFirst({
where: { assetItemId: input.assetItemId, returnedAt: null },
});
if (existingActive) {
throw new AssignmentBusinessError(
"assetItemId already has an active assignment (returnedAt IS NULL)"
);
}
// Atomic race-closing gate — see this function's doc comment.
const statusUpdate = await tx.assetItem.updateMany({
where: { id: input.assetItemId, status: "in_stock" },
data: { status: "assigned" },
});
if (statusUpdate.count === 0) {
throw new AssignmentBusinessError(
"assetItemId refers to an AssetItem that is not in_stock (lost a race to another concurrent assignment)"
);
}
}
if (input.licenseId) {
const license = await tx.license.findUnique({ where: { id: input.licenseId } });
if (!license) {
throw new AssignmentBusinessError("licenseId does not reference an existing license");
}
if (license.seatsUsed >= license.seatsTotal) {
throw new AssignmentBusinessError(
"licenseId has no seats remaining (seatsUsed >= seatsTotal)"
);
}
// Atomic race-closing gate — see this function's doc comment.
const seatUpdate = await tx.license.updateMany({
where: { id: input.licenseId, seatsUsed: { lt: license.seatsTotal } },
data: { seatsUsed: { increment: 1 } },
});
if (seatUpdate.count === 0) {
throw new AssignmentBusinessError(
"licenseId has no seats remaining (lost a race to another concurrent assignment)"
);
}
}
return tx.assignment.create({
data: {
employeeId: input.employeeId,
assetItemId: input.assetItemId ?? null,
licenseId: input.licenseId ?? null,
},
});
});
return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof AssignmentBusinessError) {
return { ok: false, error: error.message };
}
// Only employeeId can still fail with a raw FK violation here —
// assetItemId/licenseId existence is already fully resolved above
// (found-or-rejected before the transaction ever reaches
// tx.assignment.create()).
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
return { ok: false, error: "employeeId does not reference an existing employee" };
}
throw error;
}
}
export async function listAssignments(): Promise<AssignmentRecord[]> {
const rows = await prisma.assignment.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
export type ReturnAssignmentResult =
| { ok: true; value: AssignmentRecord }
| { ok: false; status: 404 | 400; error: string };
/**
* Marks an Assignment as returned (sets returnedAt), and reverses its
* side-effects from createAssignment() inside the same transaction:
* - If assetItemId is set: flips AssetItem.status back to "in_stock" —
* always, regardless of any RMA/repair path (there isn't one built yet,
* per the task's instructions; see feature-implementer report).
* - If licenseId is set: atomically decrements License.seatsUsed by 1.
*
* Race-condition note: double-return is closed the same way as
* createAssignment()'s races — an atomic, WHERE-guarded `updateMany`
* (`where: { id, returnedAt: null }`) is the actual gate. Two concurrent
* PATCH-return calls against the same Assignment race on that row's UPDATE
* lock; only the first to commit gets `count: 1`, the second gets `count: 0`
* and is rejected as "already returned". The initial `findUnique` (used to
* read assetItemId/licenseId for the AssetItem/License side-effects, and to
* distinguish "not found" from "already returned" in the common case) is
* NOT itself the race-closing check, same caveat as createAssignment()'s
* doc comment. Decrementing seatsUsed does not need its own conditional
* `updateMany` guard the way incrementing did on the assign side — a bare
* `{ decrement: 1 }` is a single atomic UPDATE with no upper bound to
* enforce (unlike assign's seatsUsed < seatsTotal check), so a plain
* `update()` is race-safe on its own here.
*/
export async function returnAssignment(
id: string,
input: { returnedAt?: Date }
): Promise<ReturnAssignmentResult> {
try {
const updated = await prisma.$transaction(async (tx) => {
const assignment = await tx.assignment.findUnique({ where: { id } });
if (!assignment) {
throw new AssignmentNotFoundError();
}
const returnedAt = input.returnedAt ?? new Date();
// Atomic race-closing gate — see this function's doc comment. Also
// doubles as the "already returned" rejection in the non-racing case,
// since `assignment.returnedAt !== null` implies this WHERE clause
// won't match either.
const claim = await tx.assignment.updateMany({
where: { id, returnedAt: null },
data: { returnedAt },
});
if (claim.count === 0) {
throw new AssignmentBusinessError("Assignment has already been returned");
}
if (assignment.assetItemId) {
await tx.assetItem.update({
where: { id: assignment.assetItemId },
data: { status: "in_stock" },
});
}
if (assignment.licenseId) {
await tx.license.update({
where: { id: assignment.licenseId },
data: { seatsUsed: { decrement: 1 } },
});
}
return tx.assignment.findUniqueOrThrow({ where: { id } });
});
return { ok: true, value: toRecord(updated) };
} catch (error) {
if (error instanceof AssignmentNotFoundError) {
return { ok: false, status: 404, error: "Assignment not found" };
}
if (error instanceof AssignmentBusinessError) {
return { ok: false, status: 400, error: error.message };
}
throw error;
}
}
export type TransferAssignmentResult =
| { ok: true; value: AssignmentRecord }
| { ok: false; status: 404 | 400; error: string };
/**
* Transfers an Assignment's underlying AssetItem/License to a new employee —
* docs/asset-license-management.md's "## Assignment (chốt 2026-08-02)":
* "Chuyển tài sản giữa nhân viên ... = đóng Assignment cũ (returnedAt = now)
* + tạo Assignment mới, cả hai trong 1 transaction." Closes the existing
* Assignment row (returnedAt = now) and creates a brand-new one for the new
* employee, reusing the SAME assetItemId/licenseId as the original, all
* inside a single `prisma.$transaction()`.
*
* Deliberately does NOT touch AssetItem.status or License.seatsUsed — unlike
* createAssignment()/returnAssignment(), a transfer is a same-resource
* handover, not a release-then-reacquire: the AssetItem never becomes
* "in_stock" and the License seat is never freed at any point during a
* transfer (no race window for a third party to grab either one mid-transfer
* — that's the whole point of doing this in one transaction instead of a
* separate PATCH-return followed by a POST-create, which is exactly the gap
* this function closes). AssetItem.status stays "assigned" throughout;
* seatsUsed is net zero (same seat, new holder).
*
* Race-condition note: mirrors returnAssignment()'s exact race-closing
* pattern — double-transfer (or transfer-racing-return) is closed by the
* same atomic, WHERE-guarded `updateMany` (`where: { id, returnedAt: null }`)
* on the OLD Assignment row. Only the first caller to commit gets
* `count: 1`; a second, concurrent call against the same id gets `count: 0`
* and is rejected as "already returned" — same semantics as
* returnAssignment(), since from the old Assignment row's point of view a
* transfer's first half *is* a return. The `findUnique` right before the
* `updateMany` is NOT the race-closing check by itself (same caveat as
* createAssignment()/returnAssignment()'s doc comments) — it only exists to
* read assetItemId/licenseId for the new row and to distinguish "not found"
* from "already returned" in the common (non-racing) case.
*/
export async function transferAssignment(
id: string,
input: { employeeId: string }
): Promise<TransferAssignmentResult> {
try {
const created = await prisma.$transaction(async (tx) => {
const assignment = await tx.assignment.findUnique({ where: { id } });
if (!assignment) {
throw new AssignmentNotFoundError();
}
// Atomic race-closing gate — see this function's doc comment. Also
// doubles as the "already returned" rejection in the non-racing case.
const claim = await tx.assignment.updateMany({
where: { id, returnedAt: null },
data: { returnedAt: new Date() },
});
if (claim.count === 0) {
throw new AssignmentBusinessError("Assignment has already been returned");
}
return tx.assignment.create({
data: {
employeeId: input.employeeId,
assetItemId: assignment.assetItemId,
licenseId: assignment.licenseId,
},
});
});
return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof AssignmentNotFoundError) {
return { ok: false, status: 404, error: "Assignment not found" };
}
if (error instanceof AssignmentBusinessError) {
return { ok: false, status: 400, error: error.message };
}
// Only employeeId can fail with a raw FK violation here — assetItemId/
// licenseId are reused verbatim from an already-valid Assignment row,
// so they're guaranteed to still reference existing rows (nothing in
// this codebase deletes AssetItem/License rows that have Assignments).
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
return {
ok: false,
status: 400,
error: "employeeId does not reference an existing employee",
};
}
throw error;
}
}
+64 -19
View File
@@ -1,4 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
export type LicenseType = "perpetual" | "subscription"; export type LicenseType = "perpetual" | "subscription";
@@ -6,20 +7,22 @@ export type LicenseType = "perpetual" | "subscription";
export interface LicenseRecord { export interface LicenseRecord {
id: string; id: string;
name: string; name: string;
vendor: string; vendor_id: string;
seats_total: number; seats_total: number;
seats_used: number; seats_used: number;
expiry_date: string | null; expiry_date: string | null;
license_type: LicenseType; license_type: LicenseType;
purchase_order_id: string | null;
created_at: string; created_at: string;
} }
export interface CreateLicenseInput { export interface CreateLicenseInput {
name: string; name: string;
vendor: string; vendorId: string;
seatsTotal: number; seatsTotal: number;
expiryDate?: Date | null; expiryDate?: Date | null;
licenseType: LicenseType; licenseType: LicenseType;
purchaseOrderId?: string | null;
} }
export type ValidationResult = export type ValidationResult =
@@ -46,7 +49,14 @@ function dateString(message: string) {
const createLicenseSchema = z.object({ const createLicenseSchema = z.object({
name: nonEmptyString("name is required and must be a non-empty string"), name: nonEmptyString("name is required and must be a non-empty string"),
vendor: nonEmptyString("vendor is required and must be a non-empty string"), // Now a real FK to Vendor (see schema.prisma) — only a UUID *format*
// check here — whether it references a real vendor is left to Prisma's
// own FK constraint at insert time (see createLicense()'s P2003 handling
// below), rather than an extra pre-check query. Mirrors the same pattern
// used for Request.employeeId in src/lib/requests.ts.
vendorId: z
.string({ required_error: "vendorId is required and must be a valid UUID string" })
.uuid("vendorId is required and must be a valid UUID string"),
seatsTotal: z seatsTotal: z
.number({ required_error: "seatsTotal is required and must be a positive integer", invalid_type_error: "seatsTotal is required and must be a positive integer" }) .number({ required_error: "seatsTotal is required and must be a positive integer", invalid_type_error: "seatsTotal is required and must be a positive integer" })
.int({ message: "seatsTotal is required and must be a positive integer" }) .int({ message: "seatsTotal is required and must be a positive integer" })
@@ -57,23 +67,36 @@ const createLicenseSchema = z.object({
message: "licenseType is required and must be either 'perpetual' or 'subscription'", message: "licenseType is required and must be either 'perpetual' or 'subscription'",
}), }),
}), }),
// FK to PurchaseOrder — nullable/optional, supply-side traceability (not
// every License goes through a formal PO). Only a UUID *format* check
// here, whether it references a real PurchaseOrder is left to Prisma's
// own FK constraint at insert time (see createLicense()'s P2003 handling
// below). Mirrors src/lib/assets.ts's purchaseOrderId
// optional().nullable() pattern exactly.
purchaseOrderId: z
.string({ invalid_type_error: "purchaseOrderId must be a valid UUID string" })
.uuid("purchaseOrderId must be a valid UUID string")
.optional()
.nullable(),
}); });
/** /**
* Validates raw (untyped) license-creation input. name/vendor must be * Validates raw (untyped) license-creation input. name must be a non-empty
* non-empty strings, seatsTotal must be a positive integer, expiryDate is * string, vendorId must be a valid UUID string, seatsTotal must be a
* optional/nullable but must be a valid date string if present, licenseType * positive integer, expiryDate is optional/nullable but must be a valid
* must be one of the two enum values. seatsUsed is not accepted here — new * date string if present, licenseType must be one of the two enum values.
* licenses always start at 0 (same pattern as AssetItem always starting * seatsUsed is not accepted here — new licenses always start at 0 (same
* "in_stock"), seats are only ever consumed via other operations (once * pattern as AssetItem always starting "in_stock"), seats are only ever
* Assignment exists), not at creation. * consumed via other operations (once Assignment exists), not at creation.
* purchaseOrderId is optional/nullable but must be a valid UUID if present.
*/ */
export function validateCreateLicenseInput(input: { export function validateCreateLicenseInput(input: {
name?: unknown; name?: unknown;
vendor?: unknown; vendorId?: unknown;
seatsTotal?: unknown; seatsTotal?: unknown;
expiryDate?: unknown; expiryDate?: unknown;
licenseType?: unknown; licenseType?: unknown;
purchaseOrderId?: unknown;
}): ValidationResult { }): ValidationResult {
const result = createLicenseSchema.safeParse(input); const result = createLicenseSchema.safeParse(input);
if (!result.success) { if (!result.success) {
@@ -86,41 +109,63 @@ export function validateCreateLicenseInput(input: {
function toRecord(row: { function toRecord(row: {
id: string; id: string;
name: string; name: string;
vendor: string; vendorId: string;
seatsTotal: number; seatsTotal: number;
seatsUsed: number; seatsUsed: number;
expiryDate: Date | null; expiryDate: Date | null;
licenseType: LicenseType; licenseType: LicenseType;
purchaseOrderId: string | null;
createdAt: Date; createdAt: Date;
}): LicenseRecord { }): LicenseRecord {
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
vendor: row.vendor, vendor_id: row.vendorId,
seats_total: row.seatsTotal, seats_total: row.seatsTotal,
seats_used: row.seatsUsed, seats_used: row.seatsUsed,
expiry_date: row.expiryDate ? row.expiryDate.toISOString() : null, expiry_date: row.expiryDate ? row.expiryDate.toISOString() : null,
license_type: row.licenseType, license_type: row.licenseType,
purchase_order_id: row.purchaseOrderId,
created_at: row.createdAt.toISOString(), created_at: row.createdAt.toISOString(),
}; };
} }
export type CreateLicenseResult =
| { ok: true; value: LicenseRecord }
| { ok: false; error: string };
/** /**
* Creates a License. No unique constraint exists on `name` (unlike * Creates a License. Two separate FKs can fail here (vendorId -> Vendor,
* AssetItem.serialNumber), so unlike createAssetItem() there's no known * purchaseOrderId -> PurchaseOrder) — Prisma raises the same error code
* Prisma error code to catch/translate here. * (P2003) for either, so the FK constraint name in `error.meta.field_name`
* is used to tell them apart and return a targeted message, rather than a
* raw Prisma error leaking to the API layer. Mirrors
* src/lib/purchase-orders.ts's createPurchaseOrder() pattern for
* disambiguating between two FKs on the same model.
*/ */
export async function createLicense(input: CreateLicenseInput): Promise<LicenseRecord> { export async function createLicense(input: CreateLicenseInput): Promise<CreateLicenseResult> {
try {
const created = await prisma.license.create({ const created = await prisma.license.create({
data: { data: {
name: input.name, name: input.name,
vendor: input.vendor, vendorId: input.vendorId,
seatsTotal: input.seatsTotal, seatsTotal: input.seatsTotal,
expiryDate: input.expiryDate ?? null, expiryDate: input.expiryDate ?? null,
licenseType: input.licenseType, licenseType: input.licenseType,
purchaseOrderId: input.purchaseOrderId ?? null,
}, },
}); });
return toRecord(created); return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
if (fieldName.includes("purchase_order")) {
return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" };
}
return { ok: false, error: "vendorId does not reference an existing vendor" };
}
throw error;
}
} }
export async function listLicenses(): Promise<LicenseRecord[]> { export async function listLicenses(): Promise<LicenseRecord[]> {
+161
View File
@@ -0,0 +1,161 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
export interface PurchaseOrderRecord {
id: string;
vendor_id: string;
order_date: string;
expected_date: string;
received_at: string | null;
received_by: string | null;
created_at: string;
}
export interface CreatePurchaseOrderInput {
vendorId: string;
orderDate: Date;
expectedDate: Date;
receivedAt?: Date | null;
receivedById?: string | null;
}
export type ValidationResult =
| { ok: true; value: CreatePurchaseOrderInput }
| { ok: false; error: string };
// Mirrors src/lib/licenses.ts's dateString helper — accepts any string
// Date.parse() can handle and transforms it into a Date for Prisma. Used
// here for orderDate/expectedDate/receivedAt, which are all Timestamptz
// (not just Date) columns, so datetime strings (not just "2026-07-31") are
// also accepted.
function dateString(message: string) {
return z
.string({ required_error: message, invalid_type_error: message })
.refine((value) => !Number.isNaN(Date.parse(value)), { message })
.transform((value) => new Date(value));
}
const createPurchaseOrderSchema = z
.object({
// FK to Vendor — only a UUID *format* check here, whether it references
// a real vendor is left to Prisma's own FK constraint at insert time
// (see createPurchaseOrder()'s P2003 handling below). Mirrors
// src/lib/licenses.ts's vendorId handling.
vendorId: z
.string({ required_error: "vendorId is required and must be a valid UUID string" })
.uuid("vendorId is required and must be a valid UUID string"),
orderDate: dateString("orderDate is required and must be a valid date string"),
expectedDate: dateString("expectedDate is required and must be a valid date string"),
// Both nullable — null means "not yet received". Co-nullability with
// receivedById is enforced by the .refine() below, not here.
receivedAt: dateString("receivedAt must be a valid date string").optional().nullable(),
receivedById: z
.string({ invalid_type_error: "receivedById must be a valid UUID string" })
.uuid("receivedById must be a valid UUID string")
.optional()
.nullable(),
})
.refine(
(value) => {
const hasReceivedAt = value.receivedAt !== undefined && value.receivedAt !== null;
const hasReceivedById = value.receivedById !== undefined && value.receivedById !== null;
return hasReceivedAt === hasReceivedById;
},
{
message: "receivedAt and receivedById must both be set or both be unset",
// Attach the error to both fields so it surfaces regardless of which
// one the caller looks at first.
path: ["receivedAt"],
}
);
/**
* Validates raw (untyped) purchase-order-creation input. vendorId must be a
* valid UUID string, orderDate/expectedDate must be valid date strings,
* receivedAt/receivedById are optional/nullable but must both be set or
* both be unset (co-nullability invariant — receiving a PO always records
* both "when" and "who" together). No status field is accepted — PurchaseOrder
* deliberately has no stored status (see schema.prisma comment), it's
* inferred from linked Requests.
*/
export function validateCreatePurchaseOrderInput(input: {
vendorId?: unknown;
orderDate?: unknown;
expectedDate?: unknown;
receivedAt?: unknown;
receivedById?: unknown;
}): ValidationResult {
const result = createPurchaseOrderSchema.safeParse(input);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid input";
return { ok: false, error: message };
}
return { ok: true, value: result.data };
}
function toRecord(row: {
id: string;
vendorId: string;
orderDate: Date;
expectedDate: Date;
receivedAt: Date | null;
receivedById: string | null;
createdAt: Date;
}): PurchaseOrderRecord {
return {
id: row.id,
vendor_id: row.vendorId,
order_date: row.orderDate.toISOString(),
expected_date: row.expectedDate.toISOString(),
received_at: row.receivedAt ? row.receivedAt.toISOString() : null,
received_by: row.receivedById,
created_at: row.createdAt.toISOString(),
};
}
export type CreatePurchaseOrderResult =
| { ok: true; value: PurchaseOrderRecord }
| { ok: false; error: string };
/**
* Creates a PurchaseOrder. Two separate FKs can fail here (vendorId ->
* Vendor, receivedById -> Employee) — Prisma raises the same error code
* (P2003) for either, so the FK constraint name in `error.meta.field_name`
* is used to tell them apart and return a targeted message, rather than a
* raw Prisma error leaking to the API layer. Mirrors
* src/lib/licenses.ts's createLicense() pattern, extended for the
* two-FK case.
*/
export async function createPurchaseOrder(
input: CreatePurchaseOrderInput
): Promise<CreatePurchaseOrderResult> {
try {
const created = await prisma.purchaseOrder.create({
data: {
vendorId: input.vendorId,
orderDate: input.orderDate,
expectedDate: input.expectedDate,
receivedAt: input.receivedAt ?? null,
receivedById: input.receivedById ?? null,
},
});
return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
if (fieldName.includes("received_by")) {
return { ok: false, error: "receivedById does not reference an existing employee" };
}
return { ok: false, error: "vendorId does not reference an existing vendor" };
}
throw error;
}
}
export async function listPurchaseOrders(): Promise<PurchaseOrderRecord[]> {
const rows = await prisma.purchaseOrder.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
+29 -5
View File
@@ -16,6 +16,7 @@ export interface RequestRecord {
type: RequestType; type: RequestType;
reason: string; reason: string;
status: RequestStatus; status: RequestStatus;
purchase_order_id: string | null;
created_at: string; created_at: string;
} }
@@ -23,6 +24,7 @@ export interface CreateRequestInput {
employee_id: string; employee_id: string;
type: RequestType; type: RequestType;
reason: string; reason: string;
purchase_order_id?: string | null;
} }
export type ValidationResult = export type ValidationResult =
@@ -55,6 +57,18 @@ const createRequestSchema = z.object({
message: "type is required and must be exactly 'asset' or 'license'", message: "type is required and must be exactly 'asset' or 'license'",
}), }),
}), }),
// Nullable/optional FK to PurchaseOrder — see schema.prisma's comment on
// Request.purchaseOrderId for the demand-vs-supply-side distinction from
// AssetItem/License's purchaseOrderId. Only a UUID *format* check here,
// whether it references a real PurchaseOrder is left to Prisma's own FK
// constraint at insert time (see createRequest()'s P2003 handling below).
// Mirrors src/lib/assets.ts's purchaseOrderId optional().nullable()
// pattern exactly.
purchase_order_id: z
.string({ invalid_type_error: "purchase_order_id must be a valid UUID string" })
.uuid("purchase_order_id must be a valid UUID string")
.optional()
.nullable(),
}); });
/** /**
@@ -66,6 +80,7 @@ export function validateCreateRequestInput(input: {
employee_id?: unknown; employee_id?: unknown;
type?: unknown; type?: unknown;
reason?: unknown; reason?: unknown;
purchase_order_id?: unknown;
}): ValidationResult { }): ValidationResult {
const result = createRequestSchema.safeParse(input); const result = createRequestSchema.safeParse(input);
if (!result.success) { if (!result.success) {
@@ -81,6 +96,7 @@ function toRecord(row: {
type: RequestType; type: RequestType;
reason: string; reason: string;
status: string; status: string;
purchaseOrderId: string | null;
createdAt: Date; createdAt: Date;
}): RequestRecord { }): RequestRecord {
return { return {
@@ -89,6 +105,7 @@ function toRecord(row: {
type: row.type, type: row.type,
reason: row.reason, reason: row.reason,
status: row.status as RequestStatus, status: row.status as RequestStatus,
purchase_order_id: row.purchaseOrderId,
created_at: row.createdAt.toISOString(), created_at: row.createdAt.toISOString(),
}; };
} }
@@ -98,11 +115,13 @@ export type CreateRequestResult =
| { ok: false; error: string }; | { ok: false; error: string };
/** /**
* Creates a Request. If employee_id doesn't reference an existing Employee, * Creates a Request. Two separate FKs can fail here (employee_id ->
* Prisma raises a known error (code P2003, FK violation) — caught here and * Employee, purchase_order_id -> PurchaseOrder) — Prisma raises the same
* translated into the same `{ok:false,error}` shape as input validation * error code (P2003) for either, so the FK constraint name in
* failures, rather than letting a raw Prisma error leak to the API layer. * `error.meta.field_name` is used to tell them apart and return a targeted
* Mirrors src/lib/employees.ts's createEmployee() pattern for managerId. * message, rather than a raw Prisma error leaking to the API layer. Mirrors
* src/lib/licenses.ts's createLicense() pattern for disambiguating between
* two FKs on the same model.
*/ */
export async function createRequest(input: CreateRequestInput): Promise<CreateRequestResult> { export async function createRequest(input: CreateRequestInput): Promise<CreateRequestResult> {
try { try {
@@ -111,11 +130,16 @@ export async function createRequest(input: CreateRequestInput): Promise<CreateRe
employeeId: input.employee_id, employeeId: input.employee_id,
type: input.type, type: input.type,
reason: input.reason, reason: input.reason,
purchaseOrderId: input.purchase_order_id ?? null,
}, },
}); });
return { ok: true, value: toRecord(created) }; return { ok: true, value: toRecord(created) };
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
if (fieldName.includes("purchase_order")) {
return { ok: false, error: "purchase_order_id does not reference an existing purchase order" };
}
return { ok: false, error: "employee_id does not reference an existing employee" }; return { ok: false, error: "employee_id does not reference an existing employee" };
} }
throw error; throw error;
+123
View File
@@ -0,0 +1,123 @@
import { z } from "zod";
import { prisma } from "./prisma";
export interface VendorRecord {
id: string;
name: string;
contact_email: string;
contact_phone: string;
address: string;
tax_code: string | null;
notes: string | null;
created_at: string;
}
export interface CreateVendorInput {
name: string;
contactEmail: string;
contactPhone: string;
address: string;
taxCode?: string | null;
notes?: string | null;
}
export type ValidationResult =
| { ok: true; value: CreateVendorInput }
| { ok: false; error: string };
// Mirrors src/lib/licenses.ts's nonEmptyString helper.
function nonEmptyString(message: string) {
return z.string({ required_error: message, invalid_type_error: message }).refine(
(value) => value.trim() !== "",
{ message }
);
}
const createVendorSchema = z.object({
name: nonEmptyString("name is required and must be a non-empty string"),
contactEmail: z
.string({
required_error: "contactEmail is required and must be a valid email",
invalid_type_error: "contactEmail is required and must be a valid email",
})
.email("contactEmail is required and must be a valid email"),
contactPhone: nonEmptyString("contactPhone is required and must be a non-empty string"),
address: nonEmptyString("address is required and must be a non-empty string"),
// Nullable, per docs/asset-license-management.md's "## Vendor" spec — not
// every vendor record has a known VN tax code at creation time.
taxCode: nonEmptyString("taxCode must be a non-empty string").optional().nullable(),
notes: nonEmptyString("notes must be a non-empty string").optional().nullable(),
});
/**
* Validates raw (untyped) vendor-creation input. name/contactPhone/address
* must be non-empty strings, contactEmail must be a valid email string,
* taxCode/notes are optional/nullable (per the "## Vendor" spec in
* docs/asset-license-management.md) but must be non-empty strings if
* present.
*/
export function validateCreateVendorInput(input: {
name?: unknown;
contactEmail?: unknown;
contactPhone?: unknown;
address?: unknown;
taxCode?: unknown;
notes?: unknown;
}): ValidationResult {
const result = createVendorSchema.safeParse(input);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid input";
return { ok: false, error: message };
}
return { ok: true, value: result.data };
}
function toRecord(row: {
id: string;
name: string;
contactEmail: string;
contactPhone: string;
address: string;
taxCode: string | null;
notes: string | null;
createdAt: Date;
}): VendorRecord {
return {
id: row.id,
name: row.name,
contact_email: row.contactEmail,
contact_phone: row.contactPhone,
address: row.address,
tax_code: row.taxCode,
notes: row.notes,
created_at: row.createdAt.toISOString(),
};
}
/**
* Creates a Vendor. No unique constraint exists on any field, no FKs
* either (see docs/asset-license-management.md's "## Vendor" spec — this
* is a standalone entity, same starting shape as Employee/License before
* they grew relations) — so unlike createAssetItem()/createEmployee()
* there's no known Prisma error code to catch/translate here.
*/
export async function createVendor(input: CreateVendorInput): Promise<VendorRecord> {
const created = await prisma.vendor.create({
data: {
name: input.name,
contactEmail: input.contactEmail,
contactPhone: input.contactPhone,
address: input.address,
taxCode: input.taxCode ?? null,
notes: input.notes ?? null,
},
});
return toRecord(created);
}
export async function listVendors(): Promise<VendorRecord[]> {
const rows = await prisma.vendor.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
+306
View File
@@ -0,0 +1,306 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
import {
createAssetCategory,
listAssetCategories,
validateCreateAssetCategoryInput,
} from "@/lib/asset-categories";
import { prisma } from "@/lib/prisma";
// Note: this file has two layers of coverage, mirroring tests/vendors.test.ts:
// - lib-level tests below exercise src/lib/asset-categories.ts directly
// (validation, valueGroup enum, list ordering).
// - route-level tests (further down) exercise
// src/app/api/asset-categories/route.ts over HTTP, including the auth()
// gate — mirrors tests/vendors.test.ts's @/auth mocking pattern.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/asset-categories/route";
import { auth } from "@/auth";
const mockedAuth = auth as unknown as Mock<() => Promise<Session | null>>;
const testSession: Session = {
user: { id: "test-user-id" },
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
};
function postRequest(body: unknown): Request {
return new Request("http://localhost/api/asset-categories", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(async () => {
// Each test starts from an empty table so listing/order assertions are
// deterministic.
await prisma.assetCategory.deleteMany();
// Default to a logged-in session — the lib-level tests below don't go
// through the route at all (so this is a no-op for them), and the
// route-level tests exercise business logic, not auth gating (that's
// covered by the "auth gating" describe block further down).
mockedAuth.mockResolvedValue(testSession);
});
describe("validateCreateAssetCategoryInput", () => {
it("accepts a valid high_value category", () => {
const result = validateCreateAssetCategoryInput({
name: "Laptop",
valueGroup: "high_value",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toMatchObject({ name: "Laptop", valueGroup: "high_value" });
}
});
it("accepts a valid low_value category", () => {
const result = validateCreateAssetCategoryInput({
name: "Cáp HDMI",
valueGroup: "low_value",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toMatchObject({ name: "Cáp HDMI", valueGroup: "low_value" });
}
});
it("rejects an empty name", () => {
const result = validateCreateAssetCategoryInput({
name: "",
valueGroup: "high_value",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/name/i);
}
});
it("rejects a missing name", () => {
const result = validateCreateAssetCategoryInput({
valueGroup: "high_value",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/name/i);
}
});
it("rejects an invalid valueGroup", () => {
const result = validateCreateAssetCategoryInput({
name: "Laptop",
valueGroup: "medium_value",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/valueGroup/i);
}
});
it("rejects a missing valueGroup", () => {
const result = validateCreateAssetCategoryInput({
name: "Laptop",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/valueGroup/i);
}
});
});
describe("createAssetCategory", () => {
it("creates a high_value category", async () => {
const result = validateCreateAssetCategoryInput({
name: "Laptop",
valueGroup: "high_value",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetCategory(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value).toMatchObject({ name: "Laptop", value_group: "high_value" });
expect(typeof created.value.id).toBe("string");
expect(created.value.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(created.value.created_at))).toBe(false);
});
it("creates a low_value category", async () => {
const result = validateCreateAssetCategoryInput({
name: "Cáp HDMI",
valueGroup: "low_value",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetCategory(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value).toMatchObject({ name: "Cáp HDMI", value_group: "low_value" });
});
});
describe("listAssetCategories", () => {
it("lists created categories, newest first", async () => {
const first = validateCreateAssetCategoryInput({ name: "Cáp HDMI", valueGroup: "low_value" });
expect(first.ok).toBe(true);
if (first.ok) await createAssetCategory(first.value);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
const second = validateCreateAssetCategoryInput({ name: "Laptop", valueGroup: "high_value" });
expect(second.ok).toBe(true);
if (second.ok) await createAssetCategory(second.value);
const records = await listAssetCategories();
expect(records).toHaveLength(2);
// newest first
expect(records[0]).toMatchObject({ name: "Laptop" });
expect(records[1]).toMatchObject({ name: "Cáp HDMI" });
});
it("returns an empty list when there are no categories", async () => {
const records = await listAssetCategories();
expect(records).toEqual([]);
});
});
describe("POST /api/asset-categories", () => {
it("creates a high_value category on valid input", async () => {
const res = await POST(postRequest({ name: "Laptop", valueGroup: "high_value" }));
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({ name: "Laptop", value_group: "high_value" });
expect(typeof body.id).toBe("string");
expect(body.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
});
it("creates a low_value category on valid input", async () => {
const res = await POST(postRequest({ name: "Cáp HDMI", valueGroup: "low_value" }));
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({ name: "Cáp HDMI", value_group: "low_value" });
});
it("rejects an empty name", async () => {
const res = await POST(postRequest({ name: "", valueGroup: "high_value" }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/name/i);
});
it("rejects an invalid valueGroup", async () => {
const res = await POST(postRequest({ name: "Laptop", valueGroup: "medium_value" }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/valueGroup/i);
});
it("rejects a missing valueGroup", async () => {
const res = await POST(postRequest({ name: "Laptop" }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/valueGroup/i);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/asset-categories", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body", async () => {
// Note: arrays are `typeof "object"` in JS, so they pass the route's
// object check (same behavior as /api/vendors) — use a JSON string
// primitive here to actually exercise the "not an object" branch.
const res = await POST(postRequest("not an object"));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
});
describe("GET /api/asset-categories", () => {
it("lists created categories, newest first", async () => {
await POST(postRequest({ name: "Cáp HDMI", valueGroup: "low_value" }));
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(postRequest({ name: "Laptop", valueGroup: "high_value" }));
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body).toHaveLength(2);
// newest first
expect(body[0]).toMatchObject({ name: "Laptop" });
expect(body[1]).toMatchObject({ name: "Cáp HDMI" });
});
it("returns an empty list when there are no categories", async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
});
describe("auth gating on /api/asset-categories", () => {
// Mirrors tests/vendors.test.ts's "auth gating" block — only checks
// session presence, per the flat permission model (no role/permission
// logic).
it("POST /api/asset-categories returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await POST(postRequest({ name: "Laptop", valueGroup: "high_value" }));
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
// And it must not have created anything.
const remaining = await prisma.assetCategory.findMany();
expect(remaining).toHaveLength(0);
});
it("GET /api/asset-categories returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
});
});
+660
View File
@@ -0,0 +1,660 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
import {
createAssetCategoryAttributeDef,
listAssetCategoryAttributeDefs,
validateCreateAssetCategoryAttributeDefInput,
} from "@/lib/asset-category-attribute-defs";
import { prisma } from "@/lib/prisma";
// Note: this file has two layers of coverage, mirroring
// tests/purchase-orders.test.ts:
// - lib-level tests below exercise src/lib/asset-category-attribute-defs.ts
// directly (validation, FK/unique error handling, list ordering).
// - route-level tests (further down) exercise
// src/app/api/asset-category-attribute-defs/route.ts over HTTP, including
// the auth() gate.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/asset-category-attribute-defs/route";
import { auth } from "@/auth";
const mockedAuth = auth as unknown as Mock<() => Promise<Session | null>>;
const testSession: Session = {
user: { id: "test-user-id" },
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
};
function postRequest(body: unknown): Request {
return new Request("http://localhost/api/asset-category-attribute-defs", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
// AssetCategoryAttributeDef.categoryId is a real FK to AssetCategory (see
// schema.prisma / src/lib/asset-category-attribute-defs.ts) — tests that
// need valid ids create real rows first and use their ids. Mirrors
// tests/purchase-orders.test.ts's createTestVendor()/createTestEmployee()
// helpers.
function createTestAssetCategory(overrides: { name?: string } = {}) {
return prisma.assetCategory.create({
data: {
name: overrides.name ?? "Laptop",
valueGroup: "high_value",
},
});
}
beforeEach(async () => {
// Each test starts from empty tables so listing/order/uniqueness
// assertions are deterministic. asset_category_attribute_defs must be
// cleared before asset_categories since it has an FK (ON DELETE RESTRICT)
// to it.
await prisma.assetCategoryAttributeDef.deleteMany();
await prisma.assetCategory.deleteMany();
// Default to a logged-in session — the lib-level tests below don't go
// through the route at all (so this is a no-op for them), and the
// route-level tests exercise business logic, not auth gating (that's
// covered by the "auth gating" describe block further down).
mockedAuth.mockResolvedValue(testSession);
});
describe("validateCreateAssetCategoryAttributeDefInput", () => {
it("accepts valid input", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toMatchObject({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
}
});
it("accepts each of the four fixed dataType values", () => {
for (const dataType of ["string", "number", "boolean", "date"] as const) {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: `key_${dataType}`,
label: "Label",
dataType,
required: false,
sortOrder: 0,
});
expect(result.ok).toBe(true);
}
});
it("rejects a missing categoryId", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/categoryId/i);
}
});
it("rejects a categoryId that is not a valid UUID", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "not-a-uuid",
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/categoryId/i);
}
});
it("rejects an empty key", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/key/i);
}
});
it("rejects an empty label", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "ram_gb",
label: "",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/label/i);
}
});
it("rejects an invalid dataType", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "ram_gb",
label: "RAM (GB)",
dataType: "float",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/dataType/i);
}
});
it("rejects a missing dataType", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "ram_gb",
label: "RAM (GB)",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/dataType/i);
}
});
it("rejects a non-boolean required", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: "yes",
sortOrder: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/required/i);
}
});
it("rejects a non-integer sortOrder", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1.5,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/sortOrder/i);
}
});
it("rejects a missing sortOrder", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/sortOrder/i);
}
});
});
describe("createAssetCategoryAttributeDef", () => {
it("creates an attribute def for a real category", async () => {
const category = await createTestAssetCategory();
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetCategoryAttributeDef(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value).toMatchObject({
category_id: category.id,
key: "ram_gb",
label: "RAM (GB)",
data_type: "number",
required: true,
sort_order: 1,
});
expect(typeof created.value.id).toBe("string");
expect(created.value.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(created.value.created_at))).toBe(false);
});
it("returns a clean error when categoryId doesn't reference an existing category", async () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "00000000-0000-0000-0000-000000000000",
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetCategoryAttributeDef(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/categoryId/i);
expect(created.error).toMatch(/does not reference an existing asset category/i);
const remaining = await prisma.assetCategoryAttributeDef.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a duplicate key within the same category with a clean 400-style error", async () => {
const category = await createTestAssetCategory();
const first = validateCreateAssetCategoryAttributeDefInput({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(first.ok).toBe(true);
if (first.ok) {
const firstCreated = await createAssetCategoryAttributeDef(first.value);
expect(firstCreated.ok).toBe(true);
}
const second = validateCreateAssetCategoryAttributeDefInput({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB) duplicate",
dataType: "number",
required: false,
sortOrder: 2,
});
expect(second.ok).toBe(true);
if (!second.ok) return;
const created = await createAssetCategoryAttributeDef(second.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/key/i);
const remaining = await prisma.assetCategoryAttributeDef.findMany();
expect(remaining).toHaveLength(1);
});
it("allows the same key across two different categories", async () => {
const laptop = await createTestAssetCategory({ name: "Laptop" });
const monitor = await createTestAssetCategory({ name: "Màn hình" });
const first = validateCreateAssetCategoryAttributeDefInput({
categoryId: laptop.id,
key: "weight_kg",
label: "Khối lượng (kg)",
dataType: "number",
required: false,
sortOrder: 1,
});
expect(first.ok).toBe(true);
if (first.ok) {
const firstCreated = await createAssetCategoryAttributeDef(first.value);
expect(firstCreated.ok).toBe(true);
}
const second = validateCreateAssetCategoryAttributeDefInput({
categoryId: monitor.id,
key: "weight_kg",
label: "Khối lượng (kg)",
dataType: "number",
required: false,
sortOrder: 1,
});
expect(second.ok).toBe(true);
if (!second.ok) return;
const created = await createAssetCategoryAttributeDef(second.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.category_id).toBe(monitor.id);
expect(created.value.key).toBe("weight_kg");
const remaining = await prisma.assetCategoryAttributeDef.findMany();
expect(remaining).toHaveLength(2);
});
});
describe("listAssetCategoryAttributeDefs", () => {
it("lists created attribute defs, newest first", async () => {
const category = await createTestAssetCategory();
const first = validateCreateAssetCategoryAttributeDefInput({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(first.ok).toBe(true);
if (first.ok) await createAssetCategoryAttributeDef(first.value);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
const second = validateCreateAssetCategoryAttributeDefInput({
categoryId: category.id,
key: "cpu",
label: "CPU",
dataType: "string",
required: false,
sortOrder: 2,
});
expect(second.ok).toBe(true);
if (second.ok) await createAssetCategoryAttributeDef(second.value);
const records = await listAssetCategoryAttributeDefs();
expect(records).toHaveLength(2);
// newest first
expect(records[0]).toMatchObject({ key: "cpu" });
expect(records[1]).toMatchObject({ key: "ram_gb" });
});
it("returns an empty list when there are no attribute defs", async () => {
const records = await listAssetCategoryAttributeDefs();
expect(records).toEqual([]);
});
});
describe("POST /api/asset-category-attribute-defs", () => {
it("creates an attribute def on valid input", async () => {
const category = await createTestAssetCategory();
const res = await POST(
postRequest({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({
category_id: category.id,
key: "ram_gb",
label: "RAM (GB)",
data_type: "number",
required: true,
sort_order: 1,
});
expect(typeof body.id).toBe("string");
expect(body.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
});
it("rejects an invalid dataType", async () => {
const category = await createTestAssetCategory();
const res = await POST(
postRequest({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB)",
dataType: "float",
required: true,
sortOrder: 1,
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/dataType/i);
});
it("rejects a categoryId that is a well-formed UUID but doesn't reference an existing category", async () => {
const res = await POST(
postRequest({
categoryId: "00000000-0000-0000-0000-000000000000",
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/categoryId/i);
expect(body.error).toMatch(/does not reference an existing asset category/i);
const remaining = await prisma.assetCategoryAttributeDef.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a duplicate (categoryId, key) with a clean 400, not a raw 500", async () => {
const category = await createTestAssetCategory();
const first = await POST(
postRequest({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
})
);
expect(first.status).toBe(201);
const second = await POST(
postRequest({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB) duplicate",
dataType: "number",
required: false,
sortOrder: 2,
})
);
expect(second.status).toBe(400);
const body = await second.json();
expect(body.error).toMatch(/key/i);
const remaining = await prisma.assetCategoryAttributeDef.findMany();
expect(remaining).toHaveLength(1);
});
it("allows the same key across two different categories via the route", async () => {
const laptop = await createTestAssetCategory({ name: "Laptop" });
const monitor = await createTestAssetCategory({ name: "Màn hình" });
const first = await POST(
postRequest({
categoryId: laptop.id,
key: "weight_kg",
label: "Khối lượng (kg)",
dataType: "number",
required: false,
sortOrder: 1,
})
);
expect(first.status).toBe(201);
const second = await POST(
postRequest({
categoryId: monitor.id,
key: "weight_kg",
label: "Khối lượng (kg)",
dataType: "number",
required: false,
sortOrder: 1,
})
);
expect(second.status).toBe(201);
const remaining = await prisma.assetCategoryAttributeDef.findMany();
expect(remaining).toHaveLength(2);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/asset-category-attribute-defs", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body", async () => {
// Note: arrays are `typeof "object"` in JS, so they pass the route's
// object check (same behavior as /api/asset-categories) — use a JSON
// string primitive here to actually exercise the "not an object" branch.
const res = await POST(postRequest("not an object"));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
});
describe("GET /api/asset-category-attribute-defs", () => {
it("lists created attribute defs, newest first", async () => {
const category = await createTestAssetCategory();
await POST(
postRequest({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
})
);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(
postRequest({
categoryId: category.id,
key: "cpu",
label: "CPU",
dataType: "string",
required: false,
sortOrder: 2,
})
);
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body).toHaveLength(2);
// newest first
expect(body[0]).toMatchObject({ key: "cpu" });
expect(body[1]).toMatchObject({ key: "ram_gb" });
});
it("returns an empty list when there are no attribute defs", async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
});
describe("auth gating on /api/asset-category-attribute-defs", () => {
// Mirrors tests/asset-categories.test.ts's "auth gating" block — only
// checks session presence, per the flat permission model (no
// role/permission logic).
it("POST /api/asset-category-attribute-defs returns 401 without a session", async () => {
const category = await createTestAssetCategory();
mockedAuth.mockResolvedValue(null);
const res = await POST(
postRequest({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
})
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
// And it must not have created anything.
const remaining = await prisma.assetCategoryAttributeDef.findMany();
expect(remaining).toHaveLength(0);
});
it("GET /api/asset-category-attribute-defs returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
});
});
+731
View File
@@ -0,0 +1,731 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
import {
createAssetModel,
listAssetModels,
validateCreateAssetModelInput,
} from "@/lib/asset-models";
import { prisma } from "@/lib/prisma";
// Note: this file has two layers of coverage, mirroring
// tests/asset-category-attribute-defs.test.ts:
// - lib-level tests below exercise src/lib/asset-models.ts directly
// (validation, FK/business-rule error handling, list ordering).
// - route-level tests (further down) exercise
// src/app/api/asset-models/route.ts over HTTP, including the auth() gate,
// malformed JSON, and non-object bodies.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/asset-models/route";
import { auth } from "@/auth";
const mockedAuth = auth as unknown as Mock<() => Promise<Session | null>>;
const testSession: Session = {
user: { id: "test-user-id" },
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
};
function postRequest(body: unknown): Request {
return new Request("http://localhost/api/asset-models", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
// AssetModel.categoryId is a real FK to AssetCategory, and the referenced
// category must be valueGroup=high_value (see docs/
// asset-license-management.md's "Cập nhật 2026-08-02 (2)" section). Tests
// that need valid ids create real rows first and use their ids. Mirrors
// tests/asset-category-attribute-defs.test.ts's createTestAssetCategory()
// helper.
function createTestAssetCategory(
overrides: { name?: string; valueGroup?: "high_value" | "low_value" } = {}
) {
return prisma.assetCategory.create({
data: {
name: overrides.name ?? "Laptop",
valueGroup: overrides.valueGroup ?? "high_value",
},
});
}
// Helper for the dynamic-specs-validation tests below: creates an
// AssetCategoryAttributeDef row directly via Prisma (no need to go through
// src/lib/asset-category-attribute-defs.ts's validation for test setup).
// Mirrors tests/asset-category-attribute-defs.test.ts's
// createTestAssetCategory() helper pattern.
function createTestAttributeDef(overrides: {
categoryId: string;
key: string;
label?: string;
dataType: "string" | "number" | "boolean" | "date";
required: boolean;
sortOrder?: number;
}) {
return prisma.assetCategoryAttributeDef.create({
data: {
categoryId: overrides.categoryId,
key: overrides.key,
label: overrides.label ?? overrides.key,
dataType: overrides.dataType,
required: overrides.required,
sortOrder: overrides.sortOrder ?? 0,
},
});
}
beforeEach(async () => {
// Each test starts from empty tables so listing/order assertions are
// deterministic. asset_models must be cleared before asset_categories
// since it has an FK (ON DELETE RESTRICT) to it. asset_category_attribute_defs
// also has an FK (ON DELETE RESTRICT) to asset_categories, so it must be
// cleared before asset_categories too — mirrors tests/
// asset-category-attribute-defs.test.ts's cleanup order.
await prisma.assetModel.deleteMany();
await prisma.assetCategoryAttributeDef.deleteMany();
await prisma.assetCategory.deleteMany();
// Default to a logged-in session — the lib-level tests below don't go
// through the route at all (so this is a no-op for them), and the
// route-level tests exercise business logic, not auth gating (that's
// covered by the "auth gating" describe block further down).
mockedAuth.mockResolvedValue(testSession);
});
describe("validateCreateAssetModelInput", () => {
it("accepts valid input with a non-trivial specs object", () => {
const result = validateCreateAssetModelInput({
categoryId: "11111111-1111-1111-1111-111111111111",
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16, cpu: "i7-1165G7" },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toMatchObject({
categoryId: "11111111-1111-1111-1111-111111111111",
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16, cpu: "i7-1165G7" },
});
}
});
it("defaults specs to {} when omitted", () => {
const result = validateCreateAssetModelInput({
categoryId: "11111111-1111-1111-1111-111111111111",
manufacturer: "Dell",
name: "Latitude 5420",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.specs).toEqual({});
}
});
it("rejects a missing categoryId", () => {
const result = validateCreateAssetModelInput({
manufacturer: "Dell",
name: "Latitude 5420",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/categoryId/i);
}
});
it("rejects a categoryId that is not a valid UUID", () => {
const result = validateCreateAssetModelInput({
categoryId: "not-a-uuid",
manufacturer: "Dell",
name: "Latitude 5420",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/categoryId/i);
}
});
it("rejects an empty manufacturer", () => {
const result = validateCreateAssetModelInput({
categoryId: "11111111-1111-1111-1111-111111111111",
manufacturer: "",
name: "Latitude 5420",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/manufacturer/i);
}
});
it("rejects an empty name", () => {
const result = validateCreateAssetModelInput({
categoryId: "11111111-1111-1111-1111-111111111111",
manufacturer: "Dell",
name: "",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/name/i);
}
});
it("rejects a specs value that is not a plain object", () => {
const result = validateCreateAssetModelInput({
categoryId: "11111111-1111-1111-1111-111111111111",
manufacturer: "Dell",
name: "Latitude 5420",
specs: "not an object",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/specs/i);
}
});
it("rejects a specs value that is an array", () => {
const result = validateCreateAssetModelInput({
categoryId: "11111111-1111-1111-1111-111111111111",
manufacturer: "Dell",
name: "Latitude 5420",
specs: ["not", "an", "object"],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/specs/i);
}
});
});
describe("createAssetModel", () => {
it("creates a model for a real high_value category, with a non-trivial specs object", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await createTestAttributeDef({
categoryId: category.id,
key: "ram_gb",
dataType: "number",
required: true,
});
await createTestAttributeDef({
categoryId: category.id,
key: "cpu",
dataType: "string",
required: true,
});
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16, cpu: "i7-1165G7" },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value).toMatchObject({
category_id: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16, cpu: "i7-1165G7" },
});
expect(typeof created.value.id).toBe("string");
expect(created.value.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(created.value.created_at))).toBe(false);
});
it("defaults specs to {} in the created record when omitted", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Apple",
name: "MacBook Pro 14",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.specs).toEqual({});
});
it("returns a clean error when categoryId doesn't reference an existing category", async () => {
const result = validateCreateAssetModelInput({
categoryId: "00000000-0000-0000-0000-000000000000",
manufacturer: "Dell",
name: "Latitude 5420",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/categoryId/i);
expect(created.error).toMatch(/does not reference an existing asset category/i);
const remaining = await prisma.assetModel.findMany();
expect(remaining).toHaveLength(0);
});
it("returns a distinct clean error when categoryId references an existing but low_value category", async () => {
const category = await createTestAssetCategory({
name: "Cáp HDMI",
valueGroup: "low_value",
});
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/high_value/i);
// Distinct message from the "doesn't exist at all" case.
expect(created.error).not.toMatch(/does not reference an existing asset category/i);
const remaining = await prisma.assetModel.findMany();
expect(remaining).toHaveLength(0);
});
});
// Dynamic specs validation, per docs/asset-license-management.md's "Cập
// nhật 2026-08-02 (3)" section: createAssetModel() builds a Zod schema at
// runtime from the category's AssetCategoryAttributeDef rows and validates
// `specs` against it — unknown keys rejected, required keys enforced, and
// each key's value type-checked per its dataType.
describe("createAssetModel: dynamic specs validation", () => {
it("accepts specs that supply all required keys with correct types", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await createTestAttributeDef({
categoryId: category.id,
key: "ram_gb",
dataType: "number",
required: true,
});
await createTestAttributeDef({
categoryId: category.id,
key: "cpu",
dataType: "string",
required: true,
});
await createTestAttributeDef({
categoryId: category.id,
key: "has_touchscreen",
dataType: "boolean",
required: true,
});
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16, cpu: "i7-1165G7", has_touchscreen: false },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.specs).toEqual({ ram_gb: 16, cpu: "i7-1165G7", has_touchscreen: false });
});
it("rejects specs missing a required key with a clean 400-style error", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await createTestAttributeDef({
categoryId: category.id,
key: "ram_gb",
dataType: "number",
required: true,
});
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: {},
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/ram_gb/i);
const remaining = await prisma.assetModel.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a specs value of the wrong type for its dataType (string where number is expected)", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await createTestAttributeDef({
categoryId: category.id,
key: "ram_gb",
dataType: "number",
required: true,
});
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: "16" },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/ram_gb/i);
const remaining = await prisma.assetModel.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a specs key not defined as an AssetCategoryAttributeDef for that category", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await createTestAttributeDef({
categoryId: category.id,
key: "ram_gb",
dataType: "number",
required: true,
});
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16, unknown_key: "surprise" },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/specs/i);
const remaining = await prisma.assetModel.findMany();
expect(remaining).toHaveLength(0);
});
it("accepts specs that omit an optional (required=false) attribute def key", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await createTestAttributeDef({
categoryId: category.id,
key: "ram_gb",
dataType: "number",
required: true,
});
await createTestAttributeDef({
categoryId: category.id,
key: "weight_kg",
dataType: "number",
required: false,
});
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16 },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.specs).toEqual({ ram_gb: 16 });
});
it("accepts a valid date string for a date-typed attribute def", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await createTestAttributeDef({
categoryId: category.id,
key: "warranty_end",
dataType: "date",
required: true,
});
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { warranty_end: "2027-01-01" },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.specs).toEqual({ warranty_end: "2027-01-01" });
});
});
describe("listAssetModels", () => {
it("lists created models, newest first", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
const first = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
});
expect(first.ok).toBe(true);
if (first.ok) await createAssetModel(first.value);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
const second = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Apple",
name: "MacBook Pro 14",
});
expect(second.ok).toBe(true);
if (second.ok) await createAssetModel(second.value);
const records = await listAssetModels();
expect(records).toHaveLength(2);
// newest first
expect(records[0]).toMatchObject({ name: "MacBook Pro 14" });
expect(records[1]).toMatchObject({ name: "Latitude 5420" });
});
it("returns an empty list when there are no models", async () => {
const records = await listAssetModels();
expect(records).toEqual([]);
});
});
describe("POST /api/asset-models", () => {
it("creates a model on valid input", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await createTestAttributeDef({
categoryId: category.id,
key: "ram_gb",
dataType: "number",
required: true,
});
await createTestAttributeDef({
categoryId: category.id,
key: "cpu",
dataType: "string",
required: true,
});
const res = await POST(
postRequest({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16, cpu: "i7-1165G7" },
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({
category_id: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16, cpu: "i7-1165G7" },
});
expect(typeof body.id).toBe("string");
expect(body.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
});
it("defaults specs to {} when omitted from the request body", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
const res = await POST(
postRequest({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.specs).toEqual({});
});
it("rejects a missing manufacturer", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
const res = await POST(
postRequest({
categoryId: category.id,
name: "Latitude 5420",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/manufacturer/i);
});
it("rejects a categoryId that is a well-formed UUID but doesn't reference an existing category", async () => {
const res = await POST(
postRequest({
categoryId: "00000000-0000-0000-0000-000000000000",
manufacturer: "Dell",
name: "Latitude 5420",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/categoryId/i);
expect(body.error).toMatch(/does not reference an existing asset category/i);
const remaining = await prisma.assetModel.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a categoryId referencing an existing but low_value category, with a distinct 400", async () => {
const category = await createTestAssetCategory({
name: "Cáp HDMI",
valueGroup: "low_value",
});
const res = await POST(
postRequest({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/high_value/i);
const remaining = await prisma.assetModel.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/asset-models", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body", async () => {
// Note: arrays are `typeof "object"` in JS, so they pass the route's
// object check (same behavior as /api/asset-category-attribute-defs) —
// use a JSON string primitive here to actually exercise the "not an
// object" branch.
const res = await POST(postRequest("not an object"));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
});
describe("GET /api/asset-models", () => {
it("lists created models, newest first", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await POST(
postRequest({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
})
);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(
postRequest({
categoryId: category.id,
manufacturer: "Apple",
name: "MacBook Pro 14",
})
);
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body).toHaveLength(2);
// newest first
expect(body[0]).toMatchObject({ name: "MacBook Pro 14" });
expect(body[1]).toMatchObject({ name: "Latitude 5420" });
});
it("returns an empty list when there are no models", async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
});
describe("auth gating on /api/asset-models", () => {
// Mirrors tests/asset-category-attribute-defs.test.ts's "auth gating"
// block — only checks session presence, per the flat permission model (no
// role/permission logic).
it("POST /api/asset-models returns 401 without a session", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
mockedAuth.mockResolvedValue(null);
const res = await POST(
postRequest({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
})
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
// And it must not have created anything.
const remaining = await prisma.assetModel.findMany();
expect(remaining).toHaveLength(0);
});
it("GET /api/asset-models returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
});
});
+194 -18
View File
@@ -24,10 +24,78 @@ function postRequest(body: unknown): Request {
}); });
} }
// AssetItem.purchaseOrderId is a real FK to PurchaseOrder (see
// schema.prisma / src/lib/assets.ts), and PurchaseOrder.vendorId is a real
// FK to Vendor — tests that need a valid purchaseOrderId create real rows
// first and use their ids. Mirrors tests/purchase-orders.test.ts's
// createTestVendor() helper.
function createTestVendor(overrides: { name?: string } = {}) {
return prisma.vendor.create({
data: {
name: overrides.name ?? "Test Vendor",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Test Street",
},
});
}
function createTestPurchaseOrder(overrides: { vendorId?: string } = {}) {
return prisma.purchaseOrder.create({
data: {
vendorId: overrides.vendorId as string,
orderDate: new Date("2026-08-01"),
expectedDate: new Date("2026-08-10"),
},
});
}
// AssetItem.modelId is a real FK to AssetModel (see schema.prisma /
// src/lib/assets.ts), and AssetModel.categoryId is a real FK to
// AssetCategory — tests that need a valid modelId create the full chain
// first and use the model's id. Mirrors tests/asset-models.test.ts's
// createTestAssetCategory() helper. Category is always high_value here —
// AssetModel only ever references high_value categories per
// src/lib/asset-models.ts's business rule.
function createTestAssetCategory(overrides: { name?: string } = {}) {
return prisma.assetCategory.create({
data: {
name: overrides.name ?? "Laptop",
valueGroup: "high_value",
},
});
}
function createTestAssetModel(overrides: { categoryId?: string } = {}) {
return prisma.assetModel.create({
data: {
categoryId: overrides.categoryId as string,
manufacturer: "Dell",
name: "Latitude 5440",
},
});
}
async function createTestModelId(): Promise<string> {
const category = await createTestAssetCategory();
const model = await createTestAssetModel({ categoryId: category.id });
return model.id;
}
beforeEach(async () => { beforeEach(async () => {
// Each test starts from an empty table so listing/order assertions are // Each test starts from empty tables so listing/order assertions are
// deterministic. // deterministic. asset_items must be cleared before purchase_orders/
// vendors/asset_models/asset_categories since asset_items has FKs to all
// of them — deleteMany order avoids relying on cascade behavior in tests.
// purchase_orders must be cleared before vendors (ON DELETE RESTRICT to
// vendors, mirrors tests/purchase-orders.test.ts). asset_models must be
// cleared before asset_categories (ON DELETE RESTRICT, mirrors
// tests/asset-models.test.ts's cleanup order).
await prisma.assetItem.deleteMany(); await prisma.assetItem.deleteMany();
await prisma.purchaseOrder.deleteMany();
await prisma.vendor.deleteMany();
await prisma.assetModel.deleteMany();
await prisma.assetCategory.deleteMany();
// Default to a logged-in session — the tests below exercise business // Default to a logged-in session — the tests below exercise business
// logic, not auth gating (that's covered by the "auth gating" describe // logic, not auth gating (that's covered by the "auth gating" describe
// block further down). // block further down).
@@ -36,8 +104,9 @@ beforeEach(async () => {
describe("POST /api/assets", () => { describe("POST /api/assets", () => {
it("creates an asset item in in_stock status on valid input, with no warrantyEndDate", async () => { it("creates an asset item in in_stock status on valid input, with no warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST( const res = await POST(
postRequest({ serialNumber: "SN-001", model: "Dell Latitude 5440", purchaseDate: "2026-01-15" }) postRequest({ serialNumber: "SN-001", modelId, purchaseDate: "2026-01-15" })
); );
expect(res.status).toBe(201); expect(res.status).toBe(201);
@@ -45,7 +114,7 @@ describe("POST /api/assets", () => {
expect(body).toMatchObject({ expect(body).toMatchObject({
serial_number: "SN-001", serial_number: "SN-001",
model: "Dell Latitude 5440", model_id: modelId,
status: "in_stock", status: "in_stock",
warranty_end_date: null, warranty_end_date: null,
}); });
@@ -56,10 +125,11 @@ describe("POST /api/assets", () => {
}); });
it("creates an asset item with a warrantyEndDate", async () => { it("creates an asset item with a warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST( const res = await POST(
postRequest({ postRequest({
serialNumber: "SN-002", serialNumber: "SN-002",
model: "Dell Latitude 5440", modelId,
purchaseDate: "2026-01-15", purchaseDate: "2026-01-15",
warrantyEndDate: "2029-01-15", warrantyEndDate: "2029-01-15",
}) })
@@ -71,24 +141,26 @@ describe("POST /api/assets", () => {
}); });
it("rejects an empty serialNumber", async () => { it("rejects an empty serialNumber", async () => {
const res = await POST(postRequest({ serialNumber: "", model: "Dell", purchaseDate: "2026-01-15" })); const modelId = await createTestModelId();
const res = await POST(postRequest({ serialNumber: "", modelId, purchaseDate: "2026-01-15" }));
expect(res.status).toBe(400); expect(res.status).toBe(400);
const body = await res.json(); const body = await res.json();
expect(body.error).toMatch(/serialNumber/i); expect(body.error).toMatch(/serialNumber/i);
}); });
it("rejects an empty model", async () => { it("rejects a missing modelId", async () => {
const res = await POST(postRequest({ serialNumber: "SN-003", model: "", purchaseDate: "2026-01-15" })); const res = await POST(postRequest({ serialNumber: "SN-003", purchaseDate: "2026-01-15" }));
expect(res.status).toBe(400); expect(res.status).toBe(400);
const body = await res.json(); const body = await res.json();
expect(body.error).toMatch(/model/i); expect(body.error).toMatch(/modelId/i);
}); });
it("rejects an invalid purchaseDate", async () => { it("rejects an invalid purchaseDate", async () => {
const modelId = await createTestModelId();
const res = await POST( const res = await POST(
postRequest({ serialNumber: "SN-004", model: "Dell", purchaseDate: "not-a-date" }) postRequest({ serialNumber: "SN-004", modelId, purchaseDate: "not-a-date" })
); );
expect(res.status).toBe(400); expect(res.status).toBe(400);
@@ -97,10 +169,11 @@ describe("POST /api/assets", () => {
}); });
it("rejects an invalid warrantyEndDate", async () => { it("rejects an invalid warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST( const res = await POST(
postRequest({ postRequest({
serialNumber: "SN-005", serialNumber: "SN-005",
model: "Dell", modelId,
purchaseDate: "2026-01-15", purchaseDate: "2026-01-15",
warrantyEndDate: "not-a-date", warrantyEndDate: "not-a-date",
}) })
@@ -111,9 +184,77 @@ describe("POST /api/assets", () => {
expect(body.error).toMatch(/warrantyEndDate/i); expect(body.error).toMatch(/warrantyEndDate/i);
}); });
it("creates an asset item with a purchaseOrderId that references a real purchase order", async () => {
const modelId = await createTestModelId();
const vendor = await createTestVendor();
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
const res = await POST(
postRequest({
serialNumber: "SN-007",
modelId,
purchaseDate: "2026-01-15",
purchaseOrderId: purchaseOrder.id,
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.purchase_order_id).toBe(purchaseOrder.id);
});
it("creates an asset item with no purchaseOrderId (stays null)", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({ serialNumber: "SN-008", modelId, purchaseDate: "2026-01-15" })
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.purchase_order_id).toBeNull();
});
it("rejects a purchaseOrderId that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({
serialNumber: "SN-009",
modelId,
purchaseDate: "2026-01-15",
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/purchaseOrderId/i);
expect(body.error).toMatch(/does not reference an existing purchase order/i);
// And it must not have created anything.
const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a purchaseOrderId that is not a valid UUID", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({
serialNumber: "SN-012",
modelId,
purchaseDate: "2026-01-15",
purchaseOrderId: "not-a-uuid",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/purchaseOrderId/i);
});
it("rejects a duplicate serialNumber", async () => { it("rejects a duplicate serialNumber", async () => {
await POST(postRequest({ serialNumber: "SN-006", model: "Dell", purchaseDate: "2026-01-15" })); const modelId = await createTestModelId();
const res = await POST(postRequest({ serialNumber: "SN-006", model: "HP", purchaseDate: "2026-02-01" })); await POST(postRequest({ serialNumber: "SN-006", modelId, purchaseDate: "2026-01-15" }));
const res = await POST(postRequest({ serialNumber: "SN-006", modelId, purchaseDate: "2026-02-01" }));
expect(res.status).toBe(400); expect(res.status).toBe(400);
const body = await res.json(); const body = await res.json();
@@ -123,14 +264,48 @@ describe("POST /api/assets", () => {
const remaining = await prisma.assetItem.findMany(); const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(1); expect(remaining).toHaveLength(1);
}); });
it("rejects a modelId that is not a valid UUID", async () => {
const res = await POST(
postRequest({ serialNumber: "SN-013", modelId: "not-a-uuid", purchaseDate: "2026-01-15" })
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/modelId/i);
// And it must not have created anything.
const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a modelId that is a well-formed UUID but doesn't reference an existing asset model", async () => {
const res = await POST(
postRequest({
serialNumber: "SN-014",
modelId: "00000000-0000-0000-0000-000000000000",
purchaseDate: "2026-01-15",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/modelId/i);
expect(body.error).toMatch(/does not reference an existing asset model/i);
// And it must not have created anything.
const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(0);
});
}); });
describe("GET /api/assets", () => { describe("GET /api/assets", () => {
it("lists created asset items, newest first", async () => { it("lists created asset items, newest first", async () => {
await POST(postRequest({ serialNumber: "SN-010", model: "Dell", purchaseDate: "2026-01-15" })); const modelId = await createTestModelId();
await POST(postRequest({ serialNumber: "SN-010", modelId, purchaseDate: "2026-01-15" }));
// Ensure a distinct created_at ordering even on fast filesystems/clocks. // Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5)); await new Promise((resolve) => setTimeout(resolve, 5));
await POST(postRequest({ serialNumber: "SN-011", model: "HP", purchaseDate: "2026-02-01" })); await POST(postRequest({ serialNumber: "SN-011", modelId, purchaseDate: "2026-02-01" }));
const res = await GET(); const res = await GET();
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -140,8 +315,8 @@ describe("GET /api/assets", () => {
expect(body).toHaveLength(2); expect(body).toHaveLength(2);
// newest first // newest first
expect(body[0]).toMatchObject({ serial_number: "SN-011", model: "HP" }); expect(body[0]).toMatchObject({ serial_number: "SN-011", model_id: modelId });
expect(body[1]).toMatchObject({ serial_number: "SN-010", model: "Dell" }); expect(body[1]).toMatchObject({ serial_number: "SN-010", model_id: modelId });
}); });
it("returns an empty list when there are no asset items", async () => { it("returns an empty list when there are no asset items", async () => {
@@ -157,10 +332,11 @@ describe("auth gating on /api/assets", () => {
// session presence, per the flat permission model (no role/permission // session presence, per the flat permission model (no role/permission
// logic). // logic).
it("POST /api/assets returns 401 without a session", async () => { it("POST /api/assets returns 401 without a session", async () => {
const modelId = await createTestModelId();
mockedAuth.mockResolvedValue(null); mockedAuth.mockResolvedValue(null);
const res = await POST( const res = await POST(
postRequest({ serialNumber: "SN-020", model: "Dell", purchaseDate: "2026-01-15" }) postRequest({ serialNumber: "SN-020", modelId, purchaseDate: "2026-01-15" })
); );
expect(res.status).toBe(401); expect(res.status).toBe(401);
+956
View File
@@ -0,0 +1,956 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
import {
createAssignment,
listAssignments,
returnAssignment,
transferAssignment,
validateCreateAssignmentInput,
validateReturnAssignmentInput,
validateTransferAssignmentInput,
} from "@/lib/assignments";
import { prisma } from "@/lib/prisma";
// This file has two layers of coverage, mirroring tests/licenses.test.ts:
// - lib-level tests exercise src/lib/assignments.ts directly (validation,
// transactional business rules, race conditions).
// - route-level tests exercise src/app/api/assignments/route.ts and
// src/app/api/assignments/[id]/route.ts over HTTP, including the auth()
// gate — mirrors tests/licenses.test.ts's @/auth mocking pattern.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/assignments/route";
import { PATCH } from "@/app/api/assignments/[id]/route";
import { auth } from "@/auth";
const mockedAuth = auth as unknown as Mock<() => Promise<Session | null>>;
const testSession: Session = {
user: { id: "test-user-id" },
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
};
function postRequest(body: unknown): Request {
return new Request("http://localhost/api/assignments", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function patchRequest(body: unknown): Request {
return new Request("http://localhost/api/assignments/x", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
// Assignment.employeeId is a real FK to Employee — mirrors
// tests/requests.test.ts's createTestEmployee() helper.
function createTestEmployee(overrides: { name?: string } = {}) {
return prisma.employee.create({
data: {
name: overrides.name ?? "Test Employee",
department: "IT",
},
});
}
// Assignment.assetItemId is a real FK to AssetItem, which itself requires a
// full AssetModel -> AssetCategory chain. Mirrors tests/assets.test.ts's
// createTestModelId() helper.
async function createTestModelId(): Promise<string> {
const category = await prisma.assetCategory.create({
data: { name: "Laptop", valueGroup: "high_value" },
});
const model = await prisma.assetModel.create({
data: { categoryId: category.id, manufacturer: "Dell", name: "Latitude 5440" },
});
return model.id;
}
let serialCounter = 0;
function createTestAssetItem(overrides: { modelId: string; status?: "in_stock" | "assigned" | "repair" | "retired" }) {
serialCounter += 1;
return prisma.assetItem.create({
data: {
serialNumber: `SN-ASSIGN-${serialCounter}`,
modelId: overrides.modelId,
purchaseDate: new Date("2026-01-15"),
status: overrides.status ?? "in_stock",
},
});
}
// Assignment.licenseId is a real FK to License, which itself requires a
// Vendor. Mirrors tests/licenses.test.ts's createTestVendor() helper.
function createTestVendor(overrides: { name?: string } = {}) {
return prisma.vendor.create({
data: {
name: overrides.name ?? "Test Vendor",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Test Street",
},
});
}
function createTestLicense(overrides: {
vendorId: string;
seatsTotal?: number;
seatsUsed?: number;
}) {
return prisma.license.create({
data: {
name: "Adobe Creative Cloud",
vendorId: overrides.vendorId,
seatsTotal: overrides.seatsTotal ?? 5,
seatsUsed: overrides.seatsUsed ?? 0,
licenseType: "subscription",
},
});
}
async function createTestLicenseId(overrides: { seatsTotal?: number; seatsUsed?: number } = {}): Promise<string> {
const vendor = await createTestVendor();
const license = await createTestLicense({ vendorId: vendor.id, ...overrides });
return license.id;
}
beforeEach(async () => {
// Clear in FK-safe order: assignments first (references everything else),
// then asset_items/licenses (reference purchase_orders/vendors/models),
// then the rest.
await prisma.assignment.deleteMany();
await prisma.assetItem.deleteMany();
await prisma.license.deleteMany();
await prisma.assetModel.deleteMany();
await prisma.assetCategory.deleteMany();
await prisma.purchaseOrder.deleteMany();
await prisma.vendor.deleteMany();
await prisma.employee.deleteMany();
serialCounter = 0;
// Default to a logged-in session — the lib-level tests don't go through
// the route at all (no-op for them), and the route-level tests exercise
// business logic, not auth gating (covered by the "auth gating" block).
mockedAuth.mockResolvedValue(testSession);
});
describe("validateCreateAssignmentInput", () => {
it("accepts input with only assetItemId set", () => {
const result = validateCreateAssignmentInput({
employeeId: "11111111-1111-1111-1111-111111111111",
assetItemId: "22222222-2222-2222-2222-222222222222",
});
expect(result.ok).toBe(true);
});
it("accepts input with only licenseId set", () => {
const result = validateCreateAssignmentInput({
employeeId: "11111111-1111-1111-1111-111111111111",
licenseId: "33333333-3333-3333-3333-333333333333",
});
expect(result.ok).toBe(true);
});
it("rejects input with neither assetItemId nor licenseId set", () => {
const result = validateCreateAssignmentInput({
employeeId: "11111111-1111-1111-1111-111111111111",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/exactly one/i);
}
});
it("rejects input with both assetItemId and licenseId set", () => {
const result = validateCreateAssignmentInput({
employeeId: "11111111-1111-1111-1111-111111111111",
assetItemId: "22222222-2222-2222-2222-222222222222",
licenseId: "33333333-3333-3333-3333-333333333333",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/exactly one/i);
}
});
it("rejects a missing employeeId", () => {
const result = validateCreateAssignmentInput({
assetItemId: "22222222-2222-2222-2222-222222222222",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/employeeId/i);
}
});
it("rejects a non-UUID assetItemId", () => {
const result = validateCreateAssignmentInput({
employeeId: "11111111-1111-1111-1111-111111111111",
assetItemId: "not-a-uuid",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/assetItemId/i);
}
});
});
describe("validateReturnAssignmentInput", () => {
it("accepts an empty body (returnedAt defaults to now later)", () => {
const result = validateReturnAssignmentInput({});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.returnedAt).toBeUndefined();
}
});
it("accepts an explicit returnedAt", () => {
const result = validateReturnAssignmentInput({ returnedAt: "2026-08-05" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.returnedAt).toBeInstanceOf(Date);
}
});
it("rejects an invalid returnedAt", () => {
const result = validateReturnAssignmentInput({ returnedAt: "not-a-date" });
expect(result.ok).toBe(false);
});
});
describe("createAssignment", () => {
it("assigns an in_stock AssetItem, flips its status to assigned", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.asset_item_id).toBe(assetItem.id);
expect(created.value.license_id).toBeNull();
expect(created.value.returned_at).toBeNull();
const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(updatedItem.status).toBe("assigned");
});
it.each(["repair", "retired", "assigned"] as const)(
"rejects assigning an AssetItem whose status is %s",
async (status) => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId, status });
const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/not in_stock/i);
const remaining = await prisma.assignment.findMany();
expect(remaining).toHaveLength(0);
}
);
it("rejects assigning an AssetItem that already has an active assignment (belt-and-suspenders check)", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const first = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
expect(first.ok).toBe(true);
// Manually force status back to in_stock without closing the active
// Assignment row, to exercise the explicit "active assignment already
// exists" invariant independently of the status check (data-drift
// scenario per src/lib/assignments.ts's doc comment).
await prisma.assetItem.update({ where: { id: assetItem.id }, data: { status: "in_stock" } });
const second = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
expect(second.ok).toBe(false);
if (second.ok) return;
expect(second.error).toMatch(/already has an active assignment/i);
});
it("rejects assigning an AssetItem for a second employee while the first assignment is still active", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const first = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
expect(first.ok).toBe(true);
const second = await createAssignment({ employeeId: employee2.id, assetItemId: assetItem.id });
expect(second.ok).toBe(false);
if (second.ok) return;
expect(second.error).toMatch(/not in_stock/i);
const activeAssignments = await prisma.assignment.findMany({ where: { assetItemId: assetItem.id, returnedAt: null } });
expect(activeAssignments).toHaveLength(1);
});
it("allows re-assigning an AssetItem to a new employee after it has been returned", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const first = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
expect(first.ok).toBe(true);
if (!first.ok) return;
const returned = await returnAssignment(first.value.id, {});
expect(returned.ok).toBe(true);
const second = await createAssignment({ employeeId: employee2.id, assetItemId: assetItem.id });
expect(second.ok).toBe(true);
if (!second.ok) return;
expect(second.value.employee_id).toBe(employee2.id);
const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(updatedItem.status).toBe("assigned");
});
it("returns a clean error when assetItemId doesn't reference an existing asset item", async () => {
const employee = await createTestEmployee();
const created = await createAssignment({
employeeId: employee.id,
assetItemId: "00000000-0000-0000-0000-000000000000",
});
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/assetItemId/i);
expect(created.error).toMatch(/does not reference an existing asset item/i);
});
it("returns a clean error when employeeId doesn't reference an existing employee", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({
employeeId: "00000000-0000-0000-0000-000000000000",
assetItemId: assetItem.id,
});
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/employeeId/i);
expect(created.error).toMatch(/does not reference an existing employee/i);
// And the AssetItem's status must NOT have been mutated — the
// transaction must have rolled back entirely.
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("in_stock");
});
it("assigns a license seat, increments seatsUsed", async () => {
const employee = await createTestEmployee();
const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 2 });
const created = await createAssignment({ employeeId: employee.id, licenseId });
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.license_id).toBe(licenseId);
const license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(3);
});
it("rejects assigning a license with no seats remaining (seatsUsed >= seatsTotal)", async () => {
const employee = await createTestEmployee();
const licenseId = await createTestLicenseId({ seatsTotal: 3, seatsUsed: 3 });
const created = await createAssignment({ employeeId: employee.id, licenseId });
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/no seats remaining/i);
const license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(3);
const remaining = await prisma.assignment.findMany();
expect(remaining).toHaveLength(0);
});
it("returns a clean error when licenseId doesn't reference an existing license", async () => {
const employee = await createTestEmployee();
const created = await createAssignment({
employeeId: employee.id,
licenseId: "00000000-0000-0000-0000-000000000000",
});
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/licenseId/i);
expect(created.error).toMatch(/does not reference an existing license/i);
});
it("closes the race: two concurrent createAssignment calls against a license with exactly 1 remaining seat — at least one must fail, seatsUsed never exceeds seatsTotal", async () => {
const employee1 = await createTestEmployee({ name: "Racer 1" });
const employee2 = await createTestEmployee({ name: "Racer 2" });
// seatsTotal=3, seatsUsed=2 -> exactly 1 seat remaining.
const licenseId = await createTestLicenseId({ seatsTotal: 3, seatsUsed: 2 });
const [resultA, resultB] = await Promise.all([
createAssignment({ employeeId: employee1.id, licenseId }),
createAssignment({ employeeId: employee2.id, licenseId }),
]);
const outcomes = [resultA, resultB];
const succeeded = outcomes.filter((r) => r.ok);
const failed = outcomes.filter((r) => !r.ok);
expect(succeeded).toHaveLength(1);
expect(failed).toHaveLength(1);
if (!failed[0].ok) {
expect(failed[0].error).toMatch(/no seats remaining/i);
}
const license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(3);
expect(license.seatsUsed).toBeLessThanOrEqual(license.seatsTotal);
const assignments = await prisma.assignment.findMany({ where: { licenseId } });
expect(assignments).toHaveLength(1);
});
it("closes the race: two concurrent createAssignment calls against the same in_stock AssetItem — at least one must fail", async () => {
const employee1 = await createTestEmployee({ name: "Racer 1" });
const employee2 = await createTestEmployee({ name: "Racer 2" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const [resultA, resultB] = await Promise.all([
createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id }),
createAssignment({ employeeId: employee2.id, assetItemId: assetItem.id }),
]);
const outcomes = [resultA, resultB];
const succeeded = outcomes.filter((r) => r.ok);
const failed = outcomes.filter((r) => !r.ok);
expect(succeeded).toHaveLength(1);
expect(failed).toHaveLength(1);
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("assigned");
const activeAssignments = await prisma.assignment.findMany({
where: { assetItemId: assetItem.id, returnedAt: null },
});
expect(activeAssignments).toHaveLength(1);
});
});
describe("returnAssignment", () => {
it("returns an AssetItem assignment: sets returnedAt, flips status back to in_stock", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
expect(created.ok).toBe(true);
if (!created.ok) return;
const returned = await returnAssignment(created.value.id, {});
expect(returned.ok).toBe(true);
if (!returned.ok) return;
expect(returned.value.returned_at).not.toBeNull();
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("in_stock");
});
it("accepts an explicit returnedAt", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
expect(created.ok).toBe(true);
if (!created.ok) return;
const explicitDate = new Date("2026-08-01T00:00:00.000Z");
const returned = await returnAssignment(created.value.id, { returnedAt: explicitDate });
expect(returned.ok).toBe(true);
if (!returned.ok) return;
expect(returned.value.returned_at).toBe(explicitDate.toISOString());
});
it("returns a license assignment: sets returnedAt, decrements seatsUsed", async () => {
const employee = await createTestEmployee();
const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 2 });
const created = await createAssignment({ employeeId: employee.id, licenseId });
expect(created.ok).toBe(true);
if (!created.ok) return;
let license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(3);
const returned = await returnAssignment(created.value.id, {});
expect(returned.ok).toBe(true);
license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(2);
});
it("rejects returning an already-returned assignment (double return)", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
expect(created.ok).toBe(true);
if (!created.ok) return;
const firstReturn = await returnAssignment(created.value.id, {});
expect(firstReturn.ok).toBe(true);
const secondReturn = await returnAssignment(created.value.id, {});
expect(secondReturn.ok).toBe(false);
if (secondReturn.ok) return;
expect(secondReturn.status).toBe(400);
expect(secondReturn.error).toMatch(/already been returned/i);
});
it("returns 404 when returning a nonexistent assignment", async () => {
const result = await returnAssignment("00000000-0000-0000-0000-000000000000", {});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(404);
expect(result.error).toMatch(/not found/i);
});
it("closes the race: two concurrent returnAssignment calls against the same assignment — only one succeeds", async () => {
const employee = await createTestEmployee();
const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 0 });
const created = await createAssignment({ employeeId: employee.id, licenseId });
expect(created.ok).toBe(true);
if (!created.ok) return;
const [resultA, resultB] = await Promise.all([
returnAssignment(created.value.id, {}),
returnAssignment(created.value.id, {}),
]);
const outcomes = [resultA, resultB];
const succeeded = outcomes.filter((r) => r.ok);
const failed = outcomes.filter((r) => !r.ok);
expect(succeeded).toHaveLength(1);
expect(failed).toHaveLength(1);
const license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(0);
});
});
describe("transferAssignment", () => {
it("transfers an AssetItem assignment to a new employee, keeping status assigned throughout", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
expect(created.ok).toBe(true);
if (!created.ok) return;
const transferred = await transferAssignment(created.value.id, { employeeId: employee2.id });
expect(transferred.ok).toBe(true);
if (!transferred.ok) return;
expect(transferred.value.employee_id).toBe(employee2.id);
expect(transferred.value.asset_item_id).toBe(assetItem.id);
expect(transferred.value.returned_at).toBeNull();
// The AssetItem must remain "assigned" throughout the transfer — never
// flipped to in_stock mid-way (that's the whole point of doing this in
// one transaction rather than a separate return + create).
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("assigned");
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
expect(original.returnedAt).not.toBeNull();
const rows = await prisma.assignment.findMany({ where: { assetItemId: assetItem.id } });
expect(rows).toHaveLength(2);
const newRow = rows.find((row) => row.id !== created.value.id);
expect(newRow).toBeDefined();
expect(newRow?.employeeId).toBe(employee2.id);
expect(newRow?.returnedAt).toBeNull();
});
it("transfers a License assignment to a new employee without changing seatsUsed", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 2 });
const created = await createAssignment({ employeeId: employee1.id, licenseId });
expect(created.ok).toBe(true);
if (!created.ok) return;
let license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(3);
const transferred = await transferAssignment(created.value.id, { employeeId: employee2.id });
expect(transferred.ok).toBe(true);
if (!transferred.ok) return;
expect(transferred.value.employee_id).toBe(employee2.id);
expect(transferred.value.license_id).toBe(licenseId);
license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(3);
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
expect(original.returnedAt).not.toBeNull();
});
it("rejects transferring an already-returned assignment", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
expect(created.ok).toBe(true);
if (!created.ok) return;
const returned = await returnAssignment(created.value.id, {});
expect(returned.ok).toBe(true);
const transferred = await transferAssignment(created.value.id, { employeeId: employee2.id });
expect(transferred.ok).toBe(false);
if (transferred.ok) return;
expect(transferred.status).toBe(400);
expect(transferred.error).toMatch(/already been returned/i);
});
it("returns 404 when transferring a nonexistent assignment", async () => {
const employee = await createTestEmployee();
const result = await transferAssignment("00000000-0000-0000-0000-000000000000", {
employeeId: employee.id,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(404);
expect(result.error).toMatch(/not found/i);
});
it("returns a clean 400 when transferring to a nonexistent employeeId", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
expect(created.ok).toBe(true);
if (!created.ok) return;
const transferred = await transferAssignment(created.value.id, {
employeeId: "00000000-0000-0000-0000-000000000000",
});
expect(transferred.ok).toBe(false);
if (transferred.ok) return;
expect(transferred.status).toBe(400);
expect(transferred.error).toMatch(/employeeId/i);
expect(transferred.error).toMatch(/does not reference an existing employee/i);
// Original assignment must not have been closed — the whole transfer
// must have rolled back.
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
expect(original.returnedAt).toBeNull();
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("assigned");
});
});
describe("validateTransferAssignmentInput", () => {
it("accepts a valid employeeId", () => {
const result = validateTransferAssignmentInput({
employeeId: "11111111-1111-1111-1111-111111111111",
});
expect(result.ok).toBe(true);
});
it("rejects a missing employeeId", () => {
const result = validateTransferAssignmentInput({});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/employeeId/i);
}
});
it("rejects a non-UUID employeeId", () => {
const result = validateTransferAssignmentInput({ employeeId: "not-a-uuid" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/employeeId/i);
}
});
});
describe("listAssignments", () => {
it("lists created assignments, newest first", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const item1 = await createTestAssetItem({ modelId });
const item2 = await createTestAssetItem({ modelId });
const first = await createAssignment({ employeeId: employee.id, assetItemId: item1.id });
expect(first.ok).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 5));
const second = await createAssignment({ employeeId: employee.id, assetItemId: item2.id });
expect(second.ok).toBe(true);
const records = await listAssignments();
expect(records).toHaveLength(2);
expect(records[0].asset_item_id).toBe(item2.id);
expect(records[1].asset_item_id).toBe(item1.id);
});
it("returns an empty list when there are no assignments", async () => {
const records = await listAssignments();
expect(records).toEqual([]);
});
});
describe("POST /api/assignments", () => {
it("creates an assignment on valid input", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const res = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id }));
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({ employee_id: employee.id, asset_item_id: assetItem.id, license_id: null });
});
it("rejects both assetItemId and licenseId set", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const licenseId = await createTestLicenseId();
const res = await POST(
postRequest({ employeeId: employee.id, assetItemId: assetItem.id, licenseId })
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/exactly one/i);
});
it("rejects neither assetItemId nor licenseId set", async () => {
const employee = await createTestEmployee();
const res = await POST(postRequest({ employeeId: employee.id }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/exactly one/i);
});
it("rejects assigning an AssetItem that isn't in_stock", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId, status: "repair" });
const res = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/not in_stock/i);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/assignments", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body", async () => {
const res = await POST(postRequest("not an object"));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
});
describe("GET /api/assignments", () => {
it("lists created assignments, newest first", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const item1 = await createTestAssetItem({ modelId });
const item2 = await createTestAssetItem({ modelId });
await POST(postRequest({ employeeId: employee.id, assetItemId: item1.id }));
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(postRequest({ employeeId: employee.id, assetItemId: item2.id }));
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveLength(2);
expect(body[0].asset_item_id).toBe(item2.id);
expect(body[1].asset_item_id).toBe(item1.id);
});
it("returns an empty list when there are no assignments", async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
});
describe("PATCH /api/assignments/[id]", () => {
it("returns an assignment on valid input", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id }));
const created = await createRes.json();
const res = await PATCH(patchRequest({}), { params: { id: created.id } });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.returned_at).not.toBeNull();
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("in_stock");
});
it("returns 404 for a nonexistent assignment id", async () => {
const res = await PATCH(patchRequest({}), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toMatch(/not found/i);
});
it("returns 400 for a malformed id (not a UUID)", async () => {
const res = await PATCH(patchRequest({}), { params: { id: "not-a-uuid" } });
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/UUID/i);
});
it("rejects double-return with 400", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id }));
const created = await createRes.json();
await PATCH(patchRequest({}), { params: { id: created.id } });
const res = await PATCH(patchRequest({}), { params: { id: created.id } });
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/already been returned/i);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/assignments/x", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await PATCH(req, { params: { id: "00000000-0000-0000-0000-000000000000" } });
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body", async () => {
const res = await PATCH(patchRequest("not an object"), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
it("returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await PATCH(patchRequest({}), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
});
it("transfers an assignment to a new employee on valid employeeId input", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(postRequest({ employeeId: employee1.id, assetItemId: assetItem.id }));
const created = await createRes.json();
const res = await PATCH(patchRequest({ employeeId: employee2.id }), { params: { id: created.id } });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.employee_id).toBe(employee2.id);
expect(body.asset_item_id).toBe(assetItem.id);
expect(body.returned_at).toBeNull();
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("assigned");
});
it("rejects a body with both employeeId and returnedAt with a mutual-exclusivity 400", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(postRequest({ employeeId: employee1.id, assetItemId: assetItem.id }));
const created = await createRes.json();
const res = await PATCH(
patchRequest({ employeeId: employee2.id, returnedAt: "2026-08-01" }),
{ params: { id: created.id } }
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/either employeeId.*or returnedAt/i);
});
it("returns 401 for a transfer request without a session", async () => {
const employee = await createTestEmployee();
mockedAuth.mockResolvedValue(null);
const res = await PATCH(patchRequest({ employeeId: employee.id }), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
});
});
describe("auth gating on /api/assignments", () => {
it("POST /api/assignments returns 401 without a session", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
mockedAuth.mockResolvedValue(null);
const res = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id }));
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
const remaining = await prisma.assignment.findMany();
expect(remaining).toHaveLength(0);
});
it("GET /api/assignments returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
});
});
+294 -39
View File
@@ -34,10 +34,45 @@ function postRequest(body: unknown): Request {
}); });
} }
// License.vendorId is a real FK to Vendor (see schema.prisma /
// src/lib/licenses.ts) — tests that need a valid vendorId create a real
// Vendor row first and use its id, rather than an arbitrary string. Mirrors
// tests/requests.test.ts's createTestEmployee() helper.
function createTestVendor(overrides: { name?: string } = {}) {
return prisma.vendor.create({
data: {
name: overrides.name ?? "Test Vendor",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Test Street",
},
});
}
// License.purchaseOrderId is a real FK to PurchaseOrder (see schema.prisma /
// src/lib/licenses.ts) — tests that need a valid purchaseOrderId create a
// real PurchaseOrder row first and use its id. Mirrors
// tests/assets.test.ts's createTestPurchaseOrder() helper.
function createTestPurchaseOrder(overrides: { vendorId?: string } = {}) {
return prisma.purchaseOrder.create({
data: {
vendorId: overrides.vendorId as string,
orderDate: new Date("2026-08-01"),
expectedDate: new Date("2026-08-10"),
},
});
}
beforeEach(async () => { beforeEach(async () => {
// Each test starts from an empty table so listing/order assertions are // Each test starts from empty tables so listing/order assertions are
// deterministic. // deterministic. licenses must be cleared before purchase_orders/vendors
// since licenses has FKs (ON DELETE RESTRICT to vendors, ON DELETE SET
// NULL to purchase_orders) to both. purchase_orders must be cleared
// before vendors (ON DELETE RESTRICT to vendors, mirrors
// tests/assets.test.ts).
await prisma.license.deleteMany(); await prisma.license.deleteMany();
await prisma.purchaseOrder.deleteMany();
await prisma.vendor.deleteMany();
// Default to a logged-in session — the lib-level tests below don't go // Default to a logged-in session — the lib-level tests below don't go
// through the route at all (so this is a no-op for them), and the // through the route at all (so this is a no-op for them), and the
// route-level tests exercise business logic, not auth gating (that's // route-level tests exercise business logic, not auth gating (that's
@@ -49,7 +84,7 @@ describe("validateCreateLicenseInput", () => {
it("accepts valid input with an expiryDate", () => { it("accepts valid input with an expiryDate", () => {
const result = validateCreateLicenseInput({ const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud", name: "Adobe Creative Cloud",
vendor: "Adobe", vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 10, seatsTotal: 10,
expiryDate: "2027-01-15", expiryDate: "2027-01-15",
licenseType: "subscription", licenseType: "subscription",
@@ -59,7 +94,7 @@ describe("validateCreateLicenseInput", () => {
if (result.ok) { if (result.ok) {
expect(result.value).toMatchObject({ expect(result.value).toMatchObject({
name: "Adobe Creative Cloud", name: "Adobe Creative Cloud",
vendor: "Adobe", vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 10, seatsTotal: 10,
licenseType: "subscription", licenseType: "subscription",
}); });
@@ -70,7 +105,7 @@ describe("validateCreateLicenseInput", () => {
it("accepts valid input with no expiryDate (perpetual license)", () => { it("accepts valid input with no expiryDate (perpetual license)", () => {
const result = validateCreateLicenseInput({ const result = validateCreateLicenseInput({
name: "Windows Server 2022", name: "Windows Server 2022",
vendor: "Microsoft", vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 5, seatsTotal: 5,
licenseType: "perpetual", licenseType: "perpetual",
}); });
@@ -84,7 +119,7 @@ describe("validateCreateLicenseInput", () => {
it("rejects an empty name", () => { it("rejects an empty name", () => {
const result = validateCreateLicenseInput({ const result = validateCreateLicenseInput({
name: "", name: "",
vendor: "Adobe", vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 10, seatsTotal: 10,
licenseType: "subscription", licenseType: "subscription",
}); });
@@ -95,24 +130,37 @@ describe("validateCreateLicenseInput", () => {
} }
}); });
it("rejects an empty vendor", () => { it("rejects a vendorId that is not a valid UUID", () => {
const result = validateCreateLicenseInput({ const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud", name: "Adobe Creative Cloud",
vendor: "", vendorId: "not-a-uuid",
seatsTotal: 10, seatsTotal: 10,
licenseType: "subscription", licenseType: "subscription",
}); });
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
if (!result.ok) { if (!result.ok) {
expect(result.error).toMatch(/vendor/i); expect(result.error).toMatch(/vendorId/i);
}
});
it("rejects a missing vendorId", () => {
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
seatsTotal: 10,
licenseType: "subscription",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/vendorId/i);
} }
}); });
it("rejects a zero seatsTotal", () => { it("rejects a zero seatsTotal", () => {
const result = validateCreateLicenseInput({ const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud", name: "Adobe Creative Cloud",
vendor: "Adobe", vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 0, seatsTotal: 0,
licenseType: "subscription", licenseType: "subscription",
}); });
@@ -126,7 +174,7 @@ describe("validateCreateLicenseInput", () => {
it("rejects a negative seatsTotal", () => { it("rejects a negative seatsTotal", () => {
const result = validateCreateLicenseInput({ const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud", name: "Adobe Creative Cloud",
vendor: "Adobe", vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: -5, seatsTotal: -5,
licenseType: "subscription", licenseType: "subscription",
}); });
@@ -140,7 +188,7 @@ describe("validateCreateLicenseInput", () => {
it("rejects a non-integer seatsTotal", () => { it("rejects a non-integer seatsTotal", () => {
const result = validateCreateLicenseInput({ const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud", name: "Adobe Creative Cloud",
vendor: "Adobe", vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 3.5, seatsTotal: 3.5,
licenseType: "subscription", licenseType: "subscription",
}); });
@@ -154,7 +202,7 @@ describe("validateCreateLicenseInput", () => {
it("rejects an invalid expiryDate", () => { it("rejects an invalid expiryDate", () => {
const result = validateCreateLicenseInput({ const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud", name: "Adobe Creative Cloud",
vendor: "Adobe", vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 10, seatsTotal: 10,
expiryDate: "not-a-date", expiryDate: "not-a-date",
licenseType: "subscription", licenseType: "subscription",
@@ -169,7 +217,7 @@ describe("validateCreateLicenseInput", () => {
it("rejects an invalid licenseType", () => { it("rejects an invalid licenseType", () => {
const result = validateCreateLicenseInput({ const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud", name: "Adobe Creative Cloud",
vendor: "Adobe", vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 10, seatsTotal: 10,
licenseType: "trial", licenseType: "trial",
}); });
@@ -183,7 +231,7 @@ describe("validateCreateLicenseInput", () => {
it("rejects a missing licenseType", () => { it("rejects a missing licenseType", () => {
const result = validateCreateLicenseInput({ const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud", name: "Adobe Creative Cloud",
vendor: "Adobe", vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 10, seatsTotal: 10,
}); });
@@ -192,13 +240,29 @@ describe("validateCreateLicenseInput", () => {
expect(result.error).toMatch(/licenseType/i); expect(result.error).toMatch(/licenseType/i);
} }
}); });
it("rejects a purchaseOrderId that is not a valid UUID", () => {
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 10,
licenseType: "subscription",
purchaseOrderId: "not-a-uuid",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/purchaseOrderId/i);
}
});
}); });
describe("createLicense", () => { describe("createLicense", () => {
it("creates a license with seatsUsed always 0, regardless of client input", async () => { it("creates a license with seatsUsed always 0, regardless of client input", async () => {
const vendor = await createTestVendor();
const result = validateCreateLicenseInput({ const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud", name: "Adobe Creative Cloud",
vendor: "Adobe", vendorId: vendor.id,
seatsTotal: 10, seatsTotal: 10,
// @ts-expect-error -- seatsUsed is intentionally not part of // @ts-expect-error -- seatsUsed is intentionally not part of
// CreateLicenseInput; this simulates a client trying to sneak it in. // CreateLicenseInput; this simulates a client trying to sneak it in.
@@ -209,12 +273,15 @@ describe("createLicense", () => {
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (!result.ok) return; if (!result.ok) return;
const record = await createLicense(result.value); const created = await createLicense(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
const record = created.value;
expect(record.seats_used).toBe(0); expect(record.seats_used).toBe(0);
expect(record).toMatchObject({ expect(record).toMatchObject({
name: "Adobe Creative Cloud", name: "Adobe Creative Cloud",
vendor: "Adobe", vendor_id: vendor.id,
seats_total: 10, seats_total: 10,
license_type: "subscription", license_type: "subscription",
}); });
@@ -225,28 +292,113 @@ describe("createLicense", () => {
}); });
it("creates a perpetual license with no expiryDate", async () => { it("creates a perpetual license with no expiryDate", async () => {
const vendor = await createTestVendor();
const result = validateCreateLicenseInput({ const result = validateCreateLicenseInput({
name: "Windows Server 2022", name: "Windows Server 2022",
vendor: "Microsoft", vendorId: vendor.id,
seatsTotal: 5, seatsTotal: 5,
licenseType: "perpetual", licenseType: "perpetual",
}); });
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (!result.ok) return; if (!result.ok) return;
const record = await createLicense(result.value); const created = await createLicense(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
const record = created.value;
expect(record.expiry_date).toBeNull(); expect(record.expiry_date).toBeNull();
expect(record.seats_used).toBe(0); expect(record.seats_used).toBe(0);
expect(record.license_type).toBe("perpetual"); expect(record.license_type).toBe("perpetual");
}); });
it("returns a clean error when vendorId doesn't reference an existing vendor", async () => {
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendorId: "00000000-0000-0000-0000-000000000000",
seatsTotal: 10,
licenseType: "subscription",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createLicense(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/vendorId/i);
expect(created.error).toMatch(/does not reference an existing vendor/i);
const remaining = await prisma.license.findMany();
expect(remaining).toHaveLength(0);
});
it("creates a license with a purchaseOrderId that references a real purchase order", async () => {
const vendor = await createTestVendor();
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendorId: vendor.id,
seatsTotal: 10,
licenseType: "subscription",
purchaseOrderId: purchaseOrder.id,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createLicense(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.purchase_order_id).toBe(purchaseOrder.id);
});
it("creates a license with no purchaseOrderId (stays null)", async () => {
const vendor = await createTestVendor();
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendorId: vendor.id,
seatsTotal: 10,
licenseType: "subscription",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createLicense(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.purchase_order_id).toBeNull();
});
it("returns a clean error when purchaseOrderId doesn't reference an existing purchase order", async () => {
const vendor = await createTestVendor();
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendorId: vendor.id,
seatsTotal: 10,
licenseType: "subscription",
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createLicense(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/purchaseOrderId/i);
expect(created.error).toMatch(/does not reference an existing purchase order/i);
const remaining = await prisma.license.findMany();
expect(remaining).toHaveLength(0);
});
}); });
describe("listLicenses", () => { describe("listLicenses", () => {
it("lists created licenses, newest first", async () => { it("lists created licenses, newest first", async () => {
const vendor1 = await createTestVendor({ name: "Microsoft VN" });
const vendor2 = await createTestVendor({ name: "Adobe VN" });
const first = validateCreateLicenseInput({ const first = validateCreateLicenseInput({
name: "Windows Server 2022", name: "Windows Server 2022",
vendor: "Microsoft", vendorId: vendor1.id,
seatsTotal: 5, seatsTotal: 5,
licenseType: "perpetual", licenseType: "perpetual",
}); });
@@ -258,7 +410,7 @@ describe("listLicenses", () => {
const second = validateCreateLicenseInput({ const second = validateCreateLicenseInput({
name: "Adobe Creative Cloud", name: "Adobe Creative Cloud",
vendor: "Adobe", vendorId: vendor2.id,
seatsTotal: 10, seatsTotal: 10,
licenseType: "subscription", licenseType: "subscription",
}); });
@@ -269,8 +421,8 @@ describe("listLicenses", () => {
expect(records).toHaveLength(2); expect(records).toHaveLength(2);
// newest first // newest first
expect(records[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor: "Adobe" }); expect(records[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor_id: vendor2.id });
expect(records[1]).toMatchObject({ name: "Windows Server 2022", vendor: "Microsoft" }); expect(records[1]).toMatchObject({ name: "Windows Server 2022", vendor_id: vendor1.id });
}); });
it("returns an empty list when there are no licenses", async () => { it("returns an empty list when there are no licenses", async () => {
@@ -281,10 +433,11 @@ describe("listLicenses", () => {
describe("POST /api/licenses", () => { describe("POST /api/licenses", () => {
it("creates a license with seatsUsed always 0 on valid input", async () => { it("creates a license with seatsUsed always 0 on valid input", async () => {
const vendor = await createTestVendor();
const res = await POST( const res = await POST(
postRequest({ postRequest({
name: "Adobe Creative Cloud", name: "Adobe Creative Cloud",
vendor: "Adobe", vendorId: vendor.id,
seatsTotal: 10, seatsTotal: 10,
expiryDate: "2027-01-15", expiryDate: "2027-01-15",
licenseType: "subscription", licenseType: "subscription",
@@ -296,7 +449,7 @@ describe("POST /api/licenses", () => {
expect(body).toMatchObject({ expect(body).toMatchObject({
name: "Adobe Creative Cloud", name: "Adobe Creative Cloud",
vendor: "Adobe", vendor_id: vendor.id,
seats_total: 10, seats_total: 10,
seats_used: 0, seats_used: 0,
license_type: "subscription", license_type: "subscription",
@@ -308,10 +461,11 @@ describe("POST /api/licenses", () => {
}); });
it("creates a perpetual license with no expiryDate", async () => { it("creates a perpetual license with no expiryDate", async () => {
const vendor = await createTestVendor();
const res = await POST( const res = await POST(
postRequest({ postRequest({
name: "Windows Server 2022", name: "Windows Server 2022",
vendor: "Microsoft", vendorId: vendor.id,
seatsTotal: 5, seatsTotal: 5,
licenseType: "perpetual", licenseType: "perpetual",
}) })
@@ -324,8 +478,9 @@ describe("POST /api/licenses", () => {
}); });
it("rejects an empty name", async () => { it("rejects an empty name", async () => {
const vendor = await createTestVendor();
const res = await POST( const res = await POST(
postRequest({ name: "", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" }) postRequest({ name: "", vendorId: vendor.id, seatsTotal: 10, licenseType: "subscription" })
); );
expect(res.status).toBe(400); expect(res.status).toBe(400);
@@ -333,19 +488,45 @@ describe("POST /api/licenses", () => {
expect(body.error).toMatch(/name/i); expect(body.error).toMatch(/name/i);
}); });
it("rejects an empty vendor", async () => { it("rejects a vendorId that is not a valid UUID", async () => {
const res = await POST( const res = await POST(
postRequest({ name: "Adobe Creative Cloud", vendor: "", seatsTotal: 10, licenseType: "subscription" }) postRequest({
name: "Adobe Creative Cloud",
vendorId: "not-a-uuid",
seatsTotal: 10,
licenseType: "subscription",
})
); );
expect(res.status).toBe(400); expect(res.status).toBe(400);
const body = await res.json(); const body = await res.json();
expect(body.error).toMatch(/vendor/i); expect(body.error).toMatch(/vendorId/i);
});
it("rejects a vendorId that is a well-formed UUID but doesn't reference an existing vendor", async () => {
const res = await POST(
postRequest({
name: "Adobe Creative Cloud",
vendorId: "00000000-0000-0000-0000-000000000000",
seatsTotal: 10,
licenseType: "subscription",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/vendorId/i);
expect(body.error).toMatch(/does not reference an existing vendor/i);
// And it must not have created anything.
const remaining = await prisma.license.findMany();
expect(remaining).toHaveLength(0);
}); });
it("rejects a zero seatsTotal", async () => { it("rejects a zero seatsTotal", async () => {
const vendor = await createTestVendor();
const res = await POST( const res = await POST(
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 0, licenseType: "subscription" }) postRequest({ name: "Adobe Creative Cloud", vendorId: vendor.id, seatsTotal: 0, licenseType: "subscription" })
); );
expect(res.status).toBe(400); expect(res.status).toBe(400);
@@ -354,10 +535,11 @@ describe("POST /api/licenses", () => {
}); });
it("rejects an invalid expiryDate", async () => { it("rejects an invalid expiryDate", async () => {
const vendor = await createTestVendor();
const res = await POST( const res = await POST(
postRequest({ postRequest({
name: "Adobe Creative Cloud", name: "Adobe Creative Cloud",
vendor: "Adobe", vendorId: vendor.id,
seatsTotal: 10, seatsTotal: 10,
expiryDate: "not-a-date", expiryDate: "not-a-date",
licenseType: "subscription", licenseType: "subscription",
@@ -370,8 +552,9 @@ describe("POST /api/licenses", () => {
}); });
it("rejects an invalid licenseType", async () => { it("rejects an invalid licenseType", async () => {
const vendor = await createTestVendor();
const res = await POST( const res = await POST(
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "trial" }) postRequest({ name: "Adobe Creative Cloud", vendorId: vendor.id, seatsTotal: 10, licenseType: "trial" })
); );
expect(res.status).toBe(400); expect(res.status).toBe(400);
@@ -379,6 +562,74 @@ describe("POST /api/licenses", () => {
expect(body.error).toMatch(/licenseType/i); expect(body.error).toMatch(/licenseType/i);
}); });
it("creates a license with a purchaseOrderId that references a real purchase order", async () => {
const vendor = await createTestVendor();
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
const res = await POST(
postRequest({
name: "Adobe Creative Cloud",
vendorId: vendor.id,
seatsTotal: 10,
licenseType: "subscription",
purchaseOrderId: purchaseOrder.id,
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.purchase_order_id).toBe(purchaseOrder.id);
});
it("creates a license with no purchaseOrderId (stays null)", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({ name: "Adobe Creative Cloud", vendorId: vendor.id, seatsTotal: 10, licenseType: "subscription" })
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.purchase_order_id).toBeNull();
});
it("rejects a purchaseOrderId that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({
name: "Adobe Creative Cloud",
vendorId: vendor.id,
seatsTotal: 10,
licenseType: "subscription",
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/purchaseOrderId/i);
expect(body.error).toMatch(/does not reference an existing purchase order/i);
// And it must not have created anything.
const remaining = await prisma.license.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a purchaseOrderId that is not a valid UUID", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({
name: "Adobe Creative Cloud",
vendorId: vendor.id,
seatsTotal: 10,
licenseType: "subscription",
purchaseOrderId: "not-a-uuid",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/purchaseOrderId/i);
});
it("rejects an invalid JSON body", async () => { it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/licenses", { const req = new Request("http://localhost/api/licenses", {
method: "POST", method: "POST",
@@ -406,13 +657,16 @@ describe("POST /api/licenses", () => {
describe("GET /api/licenses", () => { describe("GET /api/licenses", () => {
it("lists created licenses, newest first", async () => { it("lists created licenses, newest first", async () => {
const vendor1 = await createTestVendor({ name: "Microsoft VN" });
const vendor2 = await createTestVendor({ name: "Adobe VN" });
await POST( await POST(
postRequest({ name: "Windows Server 2022", vendor: "Microsoft", seatsTotal: 5, licenseType: "perpetual" }) postRequest({ name: "Windows Server 2022", vendorId: vendor1.id, seatsTotal: 5, licenseType: "perpetual" })
); );
// Ensure a distinct created_at ordering even on fast filesystems/clocks. // Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5)); await new Promise((resolve) => setTimeout(resolve, 5));
await POST( await POST(
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" }) postRequest({ name: "Adobe Creative Cloud", vendorId: vendor2.id, seatsTotal: 10, licenseType: "subscription" })
); );
const res = await GET(); const res = await GET();
@@ -423,8 +677,8 @@ describe("GET /api/licenses", () => {
expect(body).toHaveLength(2); expect(body).toHaveLength(2);
// newest first // newest first
expect(body[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor: "Adobe" }); expect(body[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor_id: vendor2.id });
expect(body[1]).toMatchObject({ name: "Windows Server 2022", vendor: "Microsoft" }); expect(body[1]).toMatchObject({ name: "Windows Server 2022", vendor_id: vendor1.id });
}); });
it("returns an empty list when there are no licenses", async () => { it("returns an empty list when there are no licenses", async () => {
@@ -440,10 +694,11 @@ describe("auth gating on /api/licenses", () => {
// session presence, per the flat permission model (no role/permission // session presence, per the flat permission model (no role/permission
// logic). // logic).
it("POST /api/licenses returns 401 without a session", async () => { it("POST /api/licenses returns 401 without a session", async () => {
const vendor = await createTestVendor();
mockedAuth.mockResolvedValue(null); mockedAuth.mockResolvedValue(null);
const res = await POST( const res = await POST(
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" }) postRequest({ name: "Adobe Creative Cloud", vendorId: vendor.id, seatsTotal: 10, licenseType: "subscription" })
); );
expect(res.status).toBe(401); expect(res.status).toBe(401);
+577
View File
@@ -0,0 +1,577 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
import {
createPurchaseOrder,
listPurchaseOrders,
validateCreatePurchaseOrderInput,
} from "@/lib/purchase-orders";
import { prisma } from "@/lib/prisma";
// Note: this file has two layers of coverage, mirroring tests/licenses.test.ts:
// - lib-level tests below exercise src/lib/purchase-orders.ts directly
// (validation, co-nullability refine, FK error handling, list ordering).
// - route-level tests (further down) exercise
// src/app/api/purchase-orders/route.ts over HTTP, including the auth() gate.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/purchase-orders/route";
import { auth } from "@/auth";
const mockedAuth = auth as unknown as Mock<() => Promise<Session | null>>;
const testSession: Session = {
user: { id: "test-user-id" },
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
};
function postRequest(body: unknown): Request {
return new Request("http://localhost/api/purchase-orders", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
// PurchaseOrder.vendorId/receivedById are real FKs to Vendor/Employee (see
// schema.prisma / src/lib/purchase-orders.ts) — tests that need valid ids
// create real rows first and use their ids. Mirrors
// tests/licenses.test.ts's createTestVendor() and tests/requests.test.ts's
// createTestEmployee() helpers.
function createTestVendor(overrides: { name?: string } = {}) {
return prisma.vendor.create({
data: {
name: overrides.name ?? "Test Vendor",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Test Street",
},
});
}
function createTestEmployee(overrides: { name?: string; department?: string } = {}) {
return prisma.employee.create({
data: {
name: overrides.name ?? "Test Employee",
department: overrides.department ?? "IT",
},
});
}
beforeEach(async () => {
// Each test starts from empty tables so listing/order assertions are
// deterministic. purchase_orders must be cleared before vendors/employees
// since purchase_orders has FKs (ON DELETE RESTRICT to vendors, ON DELETE
// SET NULL to employees) to both.
await prisma.purchaseOrder.deleteMany();
await prisma.vendor.deleteMany();
await prisma.employee.deleteMany();
// Default to a logged-in session — the lib-level tests below don't go
// through the route at all (so this is a no-op for them), and the
// route-level tests exercise business logic, not auth gating (that's
// covered by the "auth gating" describe block further down).
mockedAuth.mockResolvedValue(testSession);
});
describe("validateCreatePurchaseOrderInput", () => {
it("accepts valid input with no receivedAt/receivedById (not yet received)", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "11111111-1111-1111-1111-111111111111",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.vendorId).toBe("11111111-1111-1111-1111-111111111111");
expect(result.value.orderDate).toBeInstanceOf(Date);
expect(result.value.expectedDate).toBeInstanceOf(Date);
expect(result.value.receivedAt).toBeUndefined();
expect(result.value.receivedById).toBeUndefined();
}
});
it("accepts valid input with both receivedAt and receivedById explicitly null (not yet received)", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "11111111-1111-1111-1111-111111111111",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: null,
receivedById: null,
});
expect(result.ok).toBe(true);
});
it("accepts valid input with both receivedAt and receivedById set (received)", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "11111111-1111-1111-1111-111111111111",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: "2026-08-12",
receivedById: "22222222-2222-2222-2222-222222222222",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.receivedAt).toBeInstanceOf(Date);
expect(result.value.receivedById).toBe("22222222-2222-2222-2222-222222222222");
}
});
it("rejects receivedAt set without receivedById", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "11111111-1111-1111-1111-111111111111",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: "2026-08-12",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/receivedAt/i);
expect(result.error).toMatch(/receivedById/i);
}
});
it("rejects receivedById set without receivedAt", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "11111111-1111-1111-1111-111111111111",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedById: "22222222-2222-2222-2222-222222222222",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/receivedAt/i);
expect(result.error).toMatch(/receivedById/i);
}
});
it("rejects a missing vendorId", () => {
const result = validateCreatePurchaseOrderInput({
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/vendorId/i);
}
});
it("rejects a vendorId that is not a valid UUID", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "not-a-uuid",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/vendorId/i);
}
});
it("rejects a missing orderDate", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "11111111-1111-1111-1111-111111111111",
expectedDate: "2026-08-10",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/orderDate/i);
}
});
it("rejects an invalid expectedDate", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "11111111-1111-1111-1111-111111111111",
orderDate: "2026-08-01",
expectedDate: "not-a-date",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/expectedDate/i);
}
});
it("rejects a receivedById that is not a valid UUID", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "11111111-1111-1111-1111-111111111111",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: "2026-08-12",
receivedById: "not-a-uuid",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/receivedById/i);
}
});
});
describe("createPurchaseOrder", () => {
it("creates a purchase order that is not yet received", async () => {
const vendor = await createTestVendor();
const result = validateCreatePurchaseOrderInput({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createPurchaseOrder(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
const record = created.value;
expect(record.vendor_id).toBe(vendor.id);
expect(record.received_at).toBeNull();
expect(record.received_by).toBeNull();
expect(typeof record.id).toBe("string");
expect(record.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(record.order_date))).toBe(false);
expect(Number.isNaN(Date.parse(record.expected_date))).toBe(false);
expect(Number.isNaN(Date.parse(record.created_at))).toBe(false);
});
it("creates a purchase order that has already been received", async () => {
const vendor = await createTestVendor();
const employee = await createTestEmployee();
const result = validateCreatePurchaseOrderInput({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: "2026-08-12",
receivedById: employee.id,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createPurchaseOrder(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
const record = created.value;
expect(record.received_by).toBe(employee.id);
expect(Number.isNaN(Date.parse(record.received_at as string))).toBe(false);
});
it("returns a clean error when vendorId doesn't reference an existing vendor", async () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "00000000-0000-0000-0000-000000000000",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createPurchaseOrder(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/vendorId/i);
expect(created.error).toMatch(/does not reference an existing vendor/i);
const remaining = await prisma.purchaseOrder.findMany();
expect(remaining).toHaveLength(0);
});
it("returns a clean error when receivedById doesn't reference an existing employee", async () => {
const vendor = await createTestVendor();
const result = validateCreatePurchaseOrderInput({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: "2026-08-12",
receivedById: "00000000-0000-0000-0000-000000000000",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createPurchaseOrder(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/receivedById/i);
expect(created.error).toMatch(/does not reference an existing employee/i);
const remaining = await prisma.purchaseOrder.findMany();
expect(remaining).toHaveLength(0);
});
});
describe("listPurchaseOrders", () => {
it("lists created purchase orders, newest first", async () => {
const vendor1 = await createTestVendor({ name: "Microsoft VN" });
const vendor2 = await createTestVendor({ name: "Adobe VN" });
const first = validateCreatePurchaseOrderInput({
vendorId: vendor1.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
});
expect(first.ok).toBe(true);
if (first.ok) await createPurchaseOrder(first.value);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
const second = validateCreatePurchaseOrderInput({
vendorId: vendor2.id,
orderDate: "2026-08-02",
expectedDate: "2026-08-11",
});
expect(second.ok).toBe(true);
if (second.ok) await createPurchaseOrder(second.value);
const records = await listPurchaseOrders();
expect(records).toHaveLength(2);
// newest first
expect(records[0]).toMatchObject({ vendor_id: vendor2.id });
expect(records[1]).toMatchObject({ vendor_id: vendor1.id });
});
it("returns an empty list when there are no purchase orders", async () => {
const records = await listPurchaseOrders();
expect(records).toEqual([]);
});
});
describe("POST /api/purchase-orders", () => {
it("creates a purchase order that is not yet received on valid input", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({ vendor_id: vendor.id, received_at: null, received_by: null });
expect(typeof body.id).toBe("string");
expect(body.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(body.order_date))).toBe(false);
expect(Number.isNaN(Date.parse(body.expected_date))).toBe(false);
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
});
it("creates a purchase order that has already been received", async () => {
const vendor = await createTestVendor();
const employee = await createTestEmployee();
const res = await POST(
postRequest({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: "2026-08-12",
receivedById: employee.id,
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.received_by).toBe(employee.id);
expect(Number.isNaN(Date.parse(body.received_at))).toBe(false);
});
it("rejects a missing vendorId", async () => {
const res = await POST(
postRequest({ orderDate: "2026-08-01", expectedDate: "2026-08-10" })
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/vendorId/i);
});
it("rejects a vendorId that is a well-formed UUID but doesn't reference an existing vendor", async () => {
const res = await POST(
postRequest({
vendorId: "00000000-0000-0000-0000-000000000000",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/vendorId/i);
expect(body.error).toMatch(/does not reference an existing vendor/i);
// And it must not have created anything.
const remaining = await prisma.purchaseOrder.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a receivedById that is a well-formed UUID but doesn't reference an existing employee", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: "2026-08-12",
receivedById: "00000000-0000-0000-0000-000000000000",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/receivedById/i);
expect(body.error).toMatch(/does not reference an existing employee/i);
// And it must not have created anything.
const remaining = await prisma.purchaseOrder.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects receivedAt set without receivedById", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: "2026-08-12",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/receivedAt/i);
expect(body.error).toMatch(/receivedById/i);
});
it("rejects receivedById set without receivedAt", async () => {
const vendor = await createTestVendor();
const employee = await createTestEmployee();
const res = await POST(
postRequest({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedById: employee.id,
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/receivedAt/i);
expect(body.error).toMatch(/receivedById/i);
});
it("rejects an invalid expectedDate", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "not-a-date",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/expectedDate/i);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/purchase-orders", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body", async () => {
// Note: arrays are `typeof "object"` in JS, so they pass the route's
// object check (same behavior as /api/licenses) — use a JSON string
// primitive here to actually exercise the "not an object" branch.
const res = await POST(postRequest("not an object"));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
});
describe("GET /api/purchase-orders", () => {
it("lists created purchase orders, newest first", async () => {
const vendor1 = await createTestVendor({ name: "Microsoft VN" });
const vendor2 = await createTestVendor({ name: "Adobe VN" });
await POST(
postRequest({ vendorId: vendor1.id, orderDate: "2026-08-01", expectedDate: "2026-08-10" })
);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(
postRequest({ vendorId: vendor2.id, orderDate: "2026-08-02", expectedDate: "2026-08-11" })
);
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body).toHaveLength(2);
// newest first
expect(body[0]).toMatchObject({ vendor_id: vendor2.id });
expect(body[1]).toMatchObject({ vendor_id: vendor1.id });
});
it("returns an empty list when there are no purchase orders", async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
});
describe("auth gating on /api/purchase-orders", () => {
// Mirrors tests/licenses.test.ts's "auth gating" block — only checks
// session presence, per the flat permission model (no role/permission
// logic).
it("POST /api/purchase-orders returns 401 without a session", async () => {
const vendor = await createTestVendor();
mockedAuth.mockResolvedValue(null);
const res = await POST(
postRequest({ vendorId: vendor.id, orderDate: "2026-08-01", expectedDate: "2026-08-10" })
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
// And it must not have created anything.
const remaining = await prisma.purchaseOrder.findMany();
expect(remaining).toHaveLength(0);
});
it("GET /api/purchase-orders returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
});
});
+100 -2
View File
@@ -50,12 +50,44 @@ function createTestEmployee(overrides: { name?: string; department?: string } =
}); });
} }
// Request.purchaseOrderId is a real FK to PurchaseOrder (see schema.prisma /
// src/lib/requests.ts) — tests that need a valid purchase_order_id create a
// real PurchaseOrder row (and the Vendor it requires) first and use its id.
// Mirrors tests/licenses.test.ts's createTestVendor()/createTestPurchaseOrder()
// helpers.
function createTestVendor(overrides: { name?: string } = {}) {
return prisma.vendor.create({
data: {
name: overrides.name ?? "Test Vendor",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Test Street",
},
});
}
function createTestPurchaseOrder(overrides: { vendorId?: string } = {}) {
return prisma.purchaseOrder.create({
data: {
vendorId: overrides.vendorId as string,
orderDate: new Date("2026-08-01"),
expectedDate: new Date("2026-08-10"),
},
});
}
beforeEach(async () => { beforeEach(async () => {
// Each test starts from empty tables so listing/order assertions are // Each test starts from empty tables so listing/order assertions are
// deterministic. requests must be cleared before employees since // deterministic. requests must be cleared before employees/purchase_orders
// requests.employee_id has an FK (ON DELETE RESTRICT) to employees. // since requests.employee_id has an FK (ON DELETE RESTRICT) to employees
// and requests.purchase_order_id has an FK (ON DELETE SET NULL) to
// purchase_orders. purchase_orders must be cleared before employees/vendors
// (ON DELETE RESTRICT to vendors, mirrors tests/licenses.test.ts) — order:
// requests -> purchase_orders -> employees -> vendors.
await prisma.request.deleteMany(); await prisma.request.deleteMany();
await prisma.purchaseOrder.deleteMany();
await prisma.employee.deleteMany(); await prisma.employee.deleteMany();
await prisma.vendor.deleteMany();
// Default to a logged-in session — the tests below exercise request // Default to a logged-in session — the tests below exercise request
// business logic, not auth gating (that's covered by the "auth gating" // business logic, not auth gating (that's covered by the "auth gating"
// describe block further down). // describe block further down).
@@ -141,6 +173,72 @@ describe("POST /api/requests", () => {
const remaining = await prisma.request.findMany(); const remaining = await prisma.request.findMany();
expect(remaining).toHaveLength(0); expect(remaining).toHaveLength(0);
}); });
it("creates a request with a purchase_order_id that references a real purchase order", async () => {
const employee = await createTestEmployee();
const vendor = await createTestVendor();
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
const res = await POST(
postRequest({
employee_id: employee.id,
type: "asset",
reason: "New laptop for onboarding",
purchase_order_id: purchaseOrder.id,
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.purchase_order_id).toBe(purchaseOrder.id);
});
it("creates a request with no purchase_order_id (stays null)", async () => {
const employee = await createTestEmployee();
const res = await POST(
postRequest({ employee_id: employee.id, type: "asset", reason: "New laptop for onboarding" })
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.purchase_order_id).toBeNull();
});
it("rejects a purchase_order_id that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
const employee = await createTestEmployee();
const res = await POST(
postRequest({
employee_id: employee.id,
type: "asset",
reason: "New laptop for onboarding",
purchase_order_id: "00000000-0000-0000-0000-000000000000",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/purchase_order_id/i);
expect(body.error).toMatch(/does not reference an existing purchase order/i);
// And it must not have created anything.
const remaining = await prisma.request.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a purchase_order_id that is not a valid UUID", async () => {
const employee = await createTestEmployee();
const res = await POST(
postRequest({
employee_id: employee.id,
type: "asset",
reason: "New laptop for onboarding",
purchase_order_id: "not-a-uuid",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/purchase_order_id/i);
});
}); });
describe("GET /api/requests", () => { describe("GET /api/requests", () => {
+476
View File
@@ -0,0 +1,476 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
import {
createVendor,
listVendors,
validateCreateVendorInput,
} from "@/lib/vendors";
import { prisma } from "@/lib/prisma";
// Note: this file has two layers of coverage, mirroring tests/licenses.test.ts:
// - lib-level tests below exercise src/lib/vendors.ts directly (validation,
// nullable taxCode/notes, list ordering).
// - route-level tests (further down) exercise src/app/api/vendors/route.ts
// over HTTP, including the auth() gate — mirrors tests/licenses.test.ts's
// @/auth mocking pattern.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/vendors/route";
import { auth } from "@/auth";
const mockedAuth = auth as unknown as Mock<() => Promise<Session | null>>;
const testSession: Session = {
user: { id: "test-user-id" },
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
};
function postRequest(body: unknown): Request {
return new Request("http://localhost/api/vendors", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(async () => {
// Each test starts from an empty table so listing/order assertions are
// deterministic.
await prisma.vendor.deleteMany();
// Default to a logged-in session — the lib-level tests below don't go
// through the route at all (so this is a no-op for them), and the
// route-level tests exercise business logic, not auth gating (that's
// covered by the "auth gating" describe block further down).
mockedAuth.mockResolvedValue(testSession);
});
describe("validateCreateVendorInput", () => {
it("accepts valid input with taxCode and notes", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: "0301234567",
notes: "Preferred reseller for Dell laptops",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toMatchObject({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: "0301234567",
notes: "Preferred reseller for Dell laptops",
});
}
});
it("accepts valid input with no taxCode/notes", () => {
const result = validateCreateVendorInput({
name: "Synnex FPT",
contactEmail: "[email protected]",
contactPhone: "0287654321",
address: "456 Le Van Viet, Q9, HCMC",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.taxCode).toBeUndefined();
expect(result.value.notes).toBeUndefined();
}
});
it("rejects an empty name", () => {
const result = validateCreateVendorInput({
name: "",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/name/i);
}
});
it("rejects an invalid contactEmail", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "not-an-email",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/contactEmail/i);
}
});
it("rejects a missing contactEmail", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/contactEmail/i);
}
});
it("rejects an empty contactPhone", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "",
address: "123 Nguyen Van Linh, Q7, HCMC",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/contactPhone/i);
}
});
it("rejects an empty address", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/address/i);
}
});
it("rejects an empty taxCode when provided", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: "",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/taxCode/i);
}
});
it("accepts a null taxCode/notes explicitly", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: null,
notes: null,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.taxCode).toBeNull();
expect(result.value.notes).toBeNull();
}
});
});
describe("createVendor", () => {
it("creates a vendor with taxCode and notes", async () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: "0301234567",
notes: "Preferred reseller for Dell laptops",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const record = await createVendor(result.value);
expect(record).toMatchObject({
name: "FPT Trading",
contact_email: "[email protected]",
contact_phone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
tax_code: "0301234567",
notes: "Preferred reseller for Dell laptops",
});
expect(typeof record.id).toBe("string");
expect(record.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(record.created_at))).toBe(false);
});
it("creates a vendor with no taxCode/notes (both null)", async () => {
const result = validateCreateVendorInput({
name: "Synnex FPT",
contactEmail: "[email protected]",
contactPhone: "0287654321",
address: "456 Le Van Viet, Q9, HCMC",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const record = await createVendor(result.value);
expect(record.tax_code).toBeNull();
expect(record.notes).toBeNull();
});
});
describe("listVendors", () => {
it("lists created vendors, newest first", async () => {
const first = validateCreateVendorInput({
name: "Synnex FPT",
contactEmail: "[email protected]",
contactPhone: "0287654321",
address: "456 Le Van Viet, Q9, HCMC",
});
expect(first.ok).toBe(true);
if (first.ok) await createVendor(first.value);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
const second = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
});
expect(second.ok).toBe(true);
if (second.ok) await createVendor(second.value);
const records = await listVendors();
expect(records).toHaveLength(2);
// newest first
expect(records[0]).toMatchObject({ name: "FPT Trading" });
expect(records[1]).toMatchObject({ name: "Synnex FPT" });
});
it("returns an empty list when there are no vendors", async () => {
const records = await listVendors();
expect(records).toEqual([]);
});
});
describe("POST /api/vendors", () => {
it("creates a vendor on valid input", async () => {
const res = await POST(
postRequest({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: "0301234567",
notes: "Preferred reseller for Dell laptops",
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({
name: "FPT Trading",
contact_email: "[email protected]",
contact_phone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
tax_code: "0301234567",
notes: "Preferred reseller for Dell laptops",
});
expect(typeof body.id).toBe("string");
expect(body.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
});
it("creates a vendor with no taxCode/notes", async () => {
const res = await POST(
postRequest({
name: "Synnex FPT",
contactEmail: "[email protected]",
contactPhone: "0287654321",
address: "456 Le Van Viet, Q9, HCMC",
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.tax_code).toBeNull();
expect(body.notes).toBeNull();
});
it("rejects an empty name", async () => {
const res = await POST(
postRequest({
name: "",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/name/i);
});
it("rejects an invalid contactEmail", async () => {
const res = await POST(
postRequest({
name: "FPT Trading",
contactEmail: "not-an-email",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/contactEmail/i);
});
it("rejects an empty contactPhone", async () => {
const res = await POST(
postRequest({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "",
address: "123 Nguyen Van Linh, Q7, HCMC",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/contactPhone/i);
});
it("rejects an empty address", async () => {
const res = await POST(
postRequest({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/address/i);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/vendors", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body", async () => {
// Note: arrays are `typeof "object"` in JS, so they pass the route's
// object check (same behavior as /api/licenses) — use a JSON string
// primitive here to actually exercise the "not an object" branch.
const res = await POST(postRequest("not an object"));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
});
describe("GET /api/vendors", () => {
it("lists created vendors, newest first", async () => {
await POST(
postRequest({
name: "Synnex FPT",
contactEmail: "[email protected]",
contactPhone: "0287654321",
address: "456 Le Van Viet, Q9, HCMC",
})
);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(
postRequest({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
})
);
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body).toHaveLength(2);
// newest first
expect(body[0]).toMatchObject({ name: "FPT Trading" });
expect(body[1]).toMatchObject({ name: "Synnex FPT" });
});
it("returns an empty list when there are no vendors", async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
});
describe("auth gating on /api/vendors", () => {
// Mirrors tests/licenses.test.ts's "auth gating" block — only checks
// session presence, per the flat permission model (no role/permission
// logic).
it("POST /api/vendors returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await POST(
postRequest({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
})
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
// And it must not have created anything.
const remaining = await prisma.vendor.findMany();
expect(remaining).toHaveLength(0);
});
it("GET /api/vendors returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
});
});