Author SHA1 Message Date
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
36 changed files with 4551 additions and 148 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)
+59 -29
View File
@@ -23,19 +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 **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). 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 | | `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 — **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) | | `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 (high-value)/License cho 1 Employee cụ thể — ngày giao, ngày trả, biên bản ký nhận. **Không áp dụng cho consumable** (consumable track theo tồn kho, không gắn 1 người cụ thể) |
| `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 | | `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") — tách riêng 2 field, 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 | | `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 | | `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 |
| `AssetFieldDefinition` | định nghĩa 1 custom field gắn với 1 `AssetCategory` — `fieldKey`, `label`, `fieldType` (`text\|number\|date\|boolean\|dropdown`), `options` (chỉ dùng khi `fieldType = dropdown`). Xem "Custom fields theo Category" bên dưới |
| `AssetFieldValue` | giá trị thực tế của 1 `AssetFieldDefinition` trên 1 `AssetItem` cụ thể — 4 cột kiểu riêng (`valueText`/`valueNumber`/`valueDate`/`valueBoolean`), chỉ cột khớp `fieldType` được set. Xem "Custom fields theo Category" bên dưới |
## Luồng trạng thái Request ## Luồng trạng thái Request
@@ -54,36 +52,69 @@ Draft → Pending Approval → Approved → Ordered → Received → Delivered
**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 (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:** **Ràng buộc dữ liệu:**
- `AssetModel.categoryId` bắt buộc, phải trỏ tới 1 `AssetCategory` có `valueGroup = high_value`. - `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). - `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`. - `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`. - `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`). **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`).
## Custom fields theo Category (Additional Options) (spec BA 2026-08-02) ## Vendor (chốt 2026-08-02)
**Yêu cầu gốc từ stakeholder** (lấy cảm hứng từ module "Additional Options" của Snipe-IT): mỗi `AssetCategory` cho phép admin tự định nghĩa thêm các field tuỳ biến; khi tạo mới 1 `AssetItem`, tuỳ theo category của asset đó, form hiện thêm 1 section "Additional Options" chứa đúng các field đã định nghĩa cho category đó. Entity mới, độc lập với `AssetModel`.
**Gắn vào `AssetCategory`, không phải `AssetModel`** — theo đúng cách stakeholder mô tả ("với mỗi category tương ứng... custom thêm các field"). Mọi `AssetModel`/`AssetItem` thuộc cùng 1 category dùng chung 1 bộ field definition. - 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 đó.
**Lưu trữ: chuẩn hoá quan hệ (EAV), không dùng cột JSONB** — nhất quán với toàn bộ schema hiện tại (mọi entity khác đều dùng cột có kiểu/enum rõ ràng, chưa có tiền lệ JSON nào), và tránh việc các Postgres view phục vụ Power BI (đã chốt ở "Quyết định kiến trúc") phải `->>`/cast JSON cho từng field. **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).
- `AssetFieldDefinition`: `id`, `categoryId` (FK → `AssetCategory`, bắt buộc), `fieldKey` (unique **trong phạm vi 1 category**, không unique toàn cục — 2 category khác nhau có thể cùng có field `color`), `label` (tên hiển thị), `fieldType` (enum `text|number|date|boolean|dropdown`), `options` (mảng string, **bắt buộc và không rỗng/không trùng lặp nếu `fieldType = dropdown`**, bỏ trống với các type khác). ## PurchaseOrder (gộp Receipt) (chốt 2026-08-02)
- `AssetFieldValue`: `id`, `assetItemId` (FK → `AssetItem`), `fieldDefinitionId` (FK → `AssetFieldDefinition`), 4 cột giá trị kiểu riêng — `valueText`/`valueNumber`/`valueDate`/`valueBoolean` — **chỉ cột khớp với `fieldDefinitionId.fieldType` được set, các cột còn lại null**. Giữ giá trị đúng kiểu Postgres native (không stringly-typed) để filter/report được, cùng nguyên tắc với enum/date ở các entity khác.
- MVP chỉ áp dụng cho `AssetItem` (high-value, serial hóa) — **chưa áp dụng cho `ConsumableItem`** (xem câu hỏi mở #3 bên dưới, chưa chốt).
- MVP: **mọi custom field đều optional**, chưa có khái niệm `required` (xem câu hỏi mở #1).
- MVP: **chưa có khả năng sửa/xoá `AssetFieldDefinition`** sau khi tạo — đây cũng là capability sửa/xoá đầu tiên trong toàn app (mọi entity hiện tại đều mới chỉ có tạo mới + đọc, chưa có `PUT`/`PATCH`/xoá nào), nên đây không phải gap riêng của feature này mà là giới hạn chung hiện tại của app (xem câu hỏi mở #2).
**Câu hỏi mở — cần stakeholder trả lời, không tự đoán:** **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`.
1. Field có thể đánh dấu `required` không? Nếu có, chặn cứng hay chỉ cảnh báo lúc tạo `AssetItem`?
2. Khi category đã có `AssetItem` thật dùng field definition của nó — cho sửa/xoá definition không? Nếu xoá 1 field đã có giá trị: giữ nguyên giá trị cũ (chỉ ẩn khỏi form tạo mới), xoá cứng luôn cả giá trị liên quan, hay soft-delete/archive?
3. Custom field chỉ áp dụng cho `AssetItem` (high-value) hay áp dụng cả `ConsumableItem` (low-value)? Snipe-IT gốc chỉ áp dụng fieldset cho hardware serial hóa, nhưng domain doc này chưa xác nhận có áp dụng tương tự cho Ubisoft VN không.
4. Có giới hạn số lượng custom field tối đa trên 1 category không?
5. Giá trị custom field có cần search/filter được trong report (Power BI view) hoặc filter danh sách asset trong app không?
Chưa build gì cho phần này tới khi có câu trả lời cho ít nhất câu #1–#3 (câu #4–#5 có thể để mặc định hợp lý — không giới hạn số field, chưa cần search — nếu stakeholder không có ý kiến, nhưng vẫn nên hỏi trước khi `pm` chia task). - 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)
@@ -138,9 +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. - 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.
- Custom fields theo Category / "Additional Options" (2026-08-02, spec BA — xem "Custom fields theo Category" ở trên): thiết kế data model (`AssetFieldDefinition`/`AssetFieldValue`, EAV, gắn vào `AssetCategory`) đã rõ, nhưng **BỊ CHẶN** cho tới khi stakeholder trả lời ít nhất 3 câu hỏi mở: (1) field có bắt buộc (`required`) được không; (2) có cho sửa/xoá field definition sau khi category đã có asset dùng không, xử lý giá trị cũ ra sao; (3) áp dụng cho `ConsumableItem` không hay chỉ `AssetItem`. Chưa giao cho `pm`.
- 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.
@@ -150,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;
+222 -13
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,9 @@ 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[]
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,11 +87,16 @@ 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.
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")
@@ -87,6 +104,8 @@ model AssetItem {
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)
@@map("asset_items") @@map("asset_items")
@@ -102,26 +121,216 @@ 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)
@@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)
@@map("asset_models")
}
// 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 });
}
+3 -1
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, model, purchaseDate, warrantyEndDate, purchaseOrderId } =
body as Record<string, unknown>;
const validation = validateCreateAssetItemInput({ const validation = validateCreateAssetItemInput({
serialNumber, serialNumber,
model, model,
purchaseDate, purchaseDate,
warrantyEndDate, warrantyEndDate,
purchaseOrderId,
}); });
if (!validation.ok) { if (!validation.ok) {
+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 });
}
+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);
}
+153
View File
@@ -0,0 +1,153 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
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 for this task — just "a plain JSON object" (no
// arrays, no primitives at the top level). Does NOT validate keys/types
// dynamically against this model's category's AssetCategoryAttributeDef
// rows — that's a separate follow-up task per docs/
// asset-license-management.md's "Cập nhật 2026-08-02 (3)" section, out of
// scope here. 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(),
};
}
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" };
}
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);
}
+25 -1
View File
@@ -11,6 +11,7 @@ export interface AssetItemRecord {
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;
} }
@@ -19,6 +20,7 @@ export interface CreateAssetItemInput {
model: string; model: string;
purchaseDate: Date; purchaseDate: Date;
warrantyEndDate?: Date; warrantyEndDate?: Date;
purchaseOrderId?: string | null;
} }
export type ValidationResult = export type ValidationResult =
@@ -48,12 +50,24 @@ const createAssetItemSchema = z.object({
model: nonEmptyString("model is required and must be a non-empty string"), model: nonEmptyString("model is required and must be a non-empty 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/model
* must be non-empty strings, purchaseDate must be a valid date string, * must be non-empty strings, purchaseDate must be a valid date string,
* warrantyEndDate is optional but must be a valid date string if present. * warrantyEndDate is optional but must be a valid date string if present.
* purchaseOrderId is optional/nullable but must be a valid UUID if present.
* status is not accepted here — new assets always start "in_stock" (same * status is not accepted here — new assets always start "in_stock" (same
* pattern as Request always starting "Draft"), later states are set via * pattern as Request always starting "Draft"), later states are set via
* other operations, not at creation. * other operations, not at creation.
@@ -63,6 +77,7 @@ export function validateCreateAssetItemInput(input: {
model?: unknown; model?: 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) {
@@ -79,6 +94,7 @@ function toRecord(row: {
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 {
@@ -88,6 +104,7 @@ function toRecord(row: {
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 +119,10 @@ 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. If purchaseOrderId doesn't reference an
* existing PurchaseOrder, Prisma raises P2003 (FK constraint violation) —
* caught the same way, mirrors src/lib/purchase-orders.ts's
* createPurchaseOrder() P2003 handling.
*/ */
export async function createAssetItem( export async function createAssetItem(
input: CreateAssetItemInput input: CreateAssetItemInput
@@ -114,6 +134,7 @@ export async function createAssetItem(
model: input.model, model: input.model,
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 +142,9 @@ 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") {
return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" };
}
throw error; 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" });
});
});
+500
View File
@@ -0,0 +1,500 @@
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",
},
});
}
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.
await prisma.assetModel.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" });
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);
});
});
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" });
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" });
});
});
+97 -2
View File
@@ -24,10 +24,42 @@ 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"),
},
});
}
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 since asset_items has FKs (ON DELETE SET NULL to
// purchase_orders) — deleteMany order avoids relying on that cascade
// behavior in tests. purchase_orders must be cleared before vendors
// (ON DELETE RESTRICT to vendors, mirrors tests/purchase-orders.test.ts).
await prisma.assetItem.deleteMany(); await prisma.assetItem.deleteMany();
await prisma.purchaseOrder.deleteMany();
await prisma.vendor.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).
@@ -111,6 +143,69 @@ 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 vendor = await createTestVendor();
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
const res = await POST(
postRequest({
serialNumber: "SN-007",
model: "Dell Latitude 5440",
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 res = await POST(
postRequest({ serialNumber: "SN-008", model: "Dell Latitude 5440", 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 res = await POST(
postRequest({
serialNumber: "SN-009",
model: "Dell Latitude 5440",
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 res = await POST(
postRequest({
serialNumber: "SN-012",
model: "Dell Latitude 5440",
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" })); await POST(postRequest({ serialNumber: "SN-006", model: "Dell", purchaseDate: "2026-01-15" }));
const res = await POST(postRequest({ serialNumber: "SN-006", model: "HP", purchaseDate: "2026-02-01" })); const res = await POST(postRequest({ serialNumber: "SN-006", model: "HP", purchaseDate: "2026-02-01" }));
+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" });
});
});