Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e319f88a75 |
@@ -113,15 +113,10 @@ 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".
|
||||||
- **`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ẽ):**
|
- 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`.
|
||||||
- **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, task sequencing cho Vendor/PurchaseOrder/Assignment/AssetCategoryAttributeDef)
|
Cập nhật lần cuối: 2026-08-02 (deploy lên homelab ubi.toanhoang.work, License entity)
|
||||||
|
|||||||
@@ -23,17 +23,19 @@ 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` | 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 |
|
| `Vendor` | nhà cung cấp |
|
||||||
| `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 |
|
| `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 |
|
||||||
| `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) |
|
| `License` | phần mềm — tên, vendor, tổng seat, seat đã dùng, ngày hết hạn, loại (perpetual/subscription) |
|
||||||
| `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) |
|
| `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) |
|
||||||
| `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 |
|
| `PurchaseOrder` | gộp nhiều Request đã duyệt, vendor, ngày đặt, ngày dự kiến 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 |
|
| `Receipt` | nhận hàng — đối chiếu PO, ngày nhận, người nhận |
|
||||||
|
| `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"), `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 |
|
| `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 |
|
||||||
| `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
|
||||||
|
|
||||||
@@ -52,69 +54,36 @@ 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`).
|
||||||
|
|
||||||
## Vendor (chốt 2026-08-02)
|
## Custom fields theo Category (Additional Options) (spec BA 2026-08-02)
|
||||||
|
|
||||||
Entity mới, độc lập với `AssetModel`.
|
**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 đó.
|
||||||
|
|
||||||
- Field: `id`, `name`, `contactEmail`, `contactPhone`, `address`, `taxCode` (nullable — mã số thuế VN), `notes` (nullable).
|
**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.
|
||||||
- **Phân biệt quan trọng:** `Vendor` (nơi mua hàng — vd nhà phân phối/reseller VN) là khái niệm **khác** `AssetModel.manufacturer` (hãng sản xuất/OEM, vd "Dell") — không phải lúc nào cũng cùng 1 công ty (vd mua laptop Dell qua reseller "FPT Trading", không mua thẳng từ Dell). Quyết định: giữ nguyên `AssetModel.manufacturer` là string tự do như hiện tại (không đổi gì); `Vendor` chỉ dùng cho quan hệ mua sắm (`PurchaseOrder`, `License`).
|
|
||||||
- **Retrofit `License.vendor`** (hiện là string tự do) → **`License.vendorId`** (FK → `Vendor`), theo đúng tiền lệ đã có trong codebase (`Request.employeeId` string→FK khi `Employee` ra đời, `AssetItem.model` string→FK khi `AssetModel` ra đời — xem "Trạng thái hiện tại" ở `../CLAUDE.md`). Chưa có `License` thật nào trong DB production nên đây là đổi kiểu cột thuần tuý, **không cần chiến lược backfill** — giống các retrofit trước đó.
|
|
||||||
|
|
||||||
**Ràng buộc dữ liệu:**
|
**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.
|
||||||
- `License.vendorId` bắt buộc, phải trỏ tới 1 `Vendor` có thật (thay cho field `vendor` string tự do trước đây).
|
|
||||||
|
|
||||||
## PurchaseOrder (gộp Receipt) (chốt 2026-08-02)
|
- `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).
|
||||||
|
- `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).
|
||||||
|
|
||||||
**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`.
|
**Câu hỏi mở — cần stakeholder trả lời, không tự đoán:**
|
||||||
|
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?
|
||||||
|
|
||||||
- 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`).
|
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).
|
||||||
- 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)
|
||||||
|
|
||||||
@@ -169,8 +138,9 @@ 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
|
||||||
- **`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.
|
- Entity còn lại: `PurchaseOrder`, `Receipt`, `Assignment` (`Employee`, `AssetItem` đã xong 2026-07-31; `License` xong 2026-08-02, xem `../CLAUDE.md`)
|
||||||
- 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.
|
||||||
@@ -180,4 +150,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 (spec `Vendor`, `PurchaseOrder` gộp `Receipt`, `Assignment`, `AssetCategoryAttributeDef` — sẵn sàng cho `pm` chia task)
|
Cập nhật lần cuối: 2026-08-02
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
-- 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")
|
|
||||||
);
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/*
|
|
||||||
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;
|
|
||||||
+8
-34
@@ -102,20 +102,17 @@ 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). `vendorId` was a free-text `vendor` String until the "## Vendor
|
// loại). `vendor` is deliberately a plain String for now, not a separate
|
||||||
// (chốt 2026-08-02)" retrofit — same precedent as Request.employeeId
|
// Vendor entity — no Vendor entity exists yet, same "plain string for now"
|
||||||
// (string -> FK once Employee existed). No production `License` data
|
// precedent as Employee.department. `seatsUsed` is a stored counter
|
||||||
// existed at the time of this change, so this was a plain column-type
|
// (defaults to 0 at creation), not a computed value — it will only ever be
|
||||||
// migration, not a backfill. `seatsUsed` is a stored counter (defaults to 0
|
// set to 0 here since Assignment (the thing that would consume seats)
|
||||||
// at creation), not a computed value — it will only ever be set to 0 here
|
// doesn't exist yet. `expiryDate` is nullable: perpetual licenses never
|
||||||
// since Assignment (the thing that would consume seats) doesn't exist yet.
|
// expire, only subscription licenses usually have one.
|
||||||
// `expiryDate` is nullable: perpetual licenses never expire, only
|
|
||||||
// subscription licenses usually have one.
|
|
||||||
model License {
|
model License {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
name String
|
name String
|
||||||
vendorId String @map("vendor_id") @db.Uuid
|
vendor String
|
||||||
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
|
||||||
@@ -125,29 +122,6 @@ model License {
|
|||||||
@@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) — PurchaseOrder is a future
|
|
||||||
// FK from this entity, not built yet. 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[]
|
|
||||||
|
|
||||||
@@map("vendors")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ 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, vendorId, seatsTotal, expiryDate, licenseType } = body as Record<string, unknown>;
|
const { name, vendor, seatsTotal, expiryDate, licenseType } = body as Record<string, unknown>;
|
||||||
const validation = validateCreateLicenseInput({
|
const validation = validateCreateLicenseInput({
|
||||||
name,
|
name,
|
||||||
vendorId,
|
vendor,
|
||||||
seatsTotal,
|
seatsTotal,
|
||||||
expiryDate,
|
expiryDate,
|
||||||
licenseType,
|
licenseType,
|
||||||
@@ -36,10 +36,7 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const created = await createLicense(validation.value);
|
const created = await createLicense(validation.value);
|
||||||
if (!created.ok) {
|
return NextResponse.json(created, { status: 201 });
|
||||||
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> {
|
||||||
|
|||||||
Vendored
-52
@@ -1,52 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
+19
-40
@@ -1,5 +1,4 @@
|
|||||||
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";
|
||||||
@@ -7,7 +6,7 @@ export type LicenseType = "perpetual" | "subscription";
|
|||||||
export interface LicenseRecord {
|
export interface LicenseRecord {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
vendor_id: string;
|
vendor: string;
|
||||||
seats_total: number;
|
seats_total: number;
|
||||||
seats_used: number;
|
seats_used: number;
|
||||||
expiry_date: string | null;
|
expiry_date: string | null;
|
||||||
@@ -17,7 +16,7 @@ export interface LicenseRecord {
|
|||||||
|
|
||||||
export interface CreateLicenseInput {
|
export interface CreateLicenseInput {
|
||||||
name: string;
|
name: string;
|
||||||
vendorId: string;
|
vendor: string;
|
||||||
seatsTotal: number;
|
seatsTotal: number;
|
||||||
expiryDate?: Date | null;
|
expiryDate?: Date | null;
|
||||||
licenseType: LicenseType;
|
licenseType: LicenseType;
|
||||||
@@ -47,14 +46,7 @@ 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"),
|
||||||
// Now a real FK to Vendor (see schema.prisma) — only a UUID *format*
|
vendor: nonEmptyString("vendor is required and must be a non-empty string"),
|
||||||
// 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" })
|
||||||
@@ -68,17 +60,17 @@ const createLicenseSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates raw (untyped) license-creation input. name must be a non-empty
|
* Validates raw (untyped) license-creation input. name/vendor must be
|
||||||
* string, vendorId must be a valid UUID string, seatsTotal must be a
|
* non-empty strings, seatsTotal must be a positive integer, expiryDate is
|
||||||
* positive integer, expiryDate is optional/nullable but must be a valid
|
* optional/nullable but must be a valid date string if present, licenseType
|
||||||
* date string if present, licenseType must be one of the two enum values.
|
* must be one of the two enum values. seatsUsed is not accepted here — new
|
||||||
* seatsUsed is not accepted here — new licenses always start at 0 (same
|
* licenses always start at 0 (same pattern as AssetItem always starting
|
||||||
* pattern as AssetItem always starting "in_stock"), seats are only ever
|
* "in_stock"), seats are only ever consumed via other operations (once
|
||||||
* consumed via other operations (once Assignment exists), not at creation.
|
* Assignment exists), not at creation.
|
||||||
*/
|
*/
|
||||||
export function validateCreateLicenseInput(input: {
|
export function validateCreateLicenseInput(input: {
|
||||||
name?: unknown;
|
name?: unknown;
|
||||||
vendorId?: unknown;
|
vendor?: unknown;
|
||||||
seatsTotal?: unknown;
|
seatsTotal?: unknown;
|
||||||
expiryDate?: unknown;
|
expiryDate?: unknown;
|
||||||
licenseType?: unknown;
|
licenseType?: unknown;
|
||||||
@@ -94,7 +86,7 @@ export function validateCreateLicenseInput(input: {
|
|||||||
function toRecord(row: {
|
function toRecord(row: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
vendorId: string;
|
vendor: string;
|
||||||
seatsTotal: number;
|
seatsTotal: number;
|
||||||
seatsUsed: number;
|
seatsUsed: number;
|
||||||
expiryDate: Date | null;
|
expiryDate: Date | null;
|
||||||
@@ -104,7 +96,7 @@ function toRecord(row: {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
vendor_id: row.vendorId,
|
vendor: row.vendor,
|
||||||
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,
|
||||||
@@ -113,35 +105,22 @@ function toRecord(row: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateLicenseResult =
|
|
||||||
| { ok: true; value: LicenseRecord }
|
|
||||||
| { ok: false; error: string };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a License. If vendorId doesn't reference an existing Vendor,
|
* Creates a License. No unique constraint exists on `name` (unlike
|
||||||
* Prisma raises a known error (code P2003, FK violation) — caught here and
|
* AssetItem.serialNumber), so unlike createAssetItem() there's no known
|
||||||
* translated into the same `{ok:false,error}` shape as input validation
|
* Prisma error code to catch/translate here.
|
||||||
* failures, rather than letting a raw Prisma error leak to the API layer.
|
|
||||||
* Mirrors src/lib/requests.ts's createRequest() pattern for employeeId.
|
|
||||||
*/
|
*/
|
||||||
export async function createLicense(input: CreateLicenseInput): Promise<CreateLicenseResult> {
|
export async function createLicense(input: CreateLicenseInput): Promise<LicenseRecord> {
|
||||||
try {
|
|
||||||
const created = await prisma.license.create({
|
const created = await prisma.license.create({
|
||||||
data: {
|
data: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
vendorId: input.vendorId,
|
vendor: input.vendor,
|
||||||
seatsTotal: input.seatsTotal,
|
seatsTotal: input.seatsTotal,
|
||||||
expiryDate: input.expiryDate ?? null,
|
expiryDate: input.expiryDate ?? null,
|
||||||
licenseType: input.licenseType,
|
licenseType: input.licenseType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { ok: true, value: toRecord(created) };
|
return toRecord(created);
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
|
||||||
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[]> {
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
+39
-135
@@ -34,27 +34,10 @@ 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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Each test starts from empty tables so listing/order assertions are
|
// Each test starts from an empty table so listing/order assertions are
|
||||||
// deterministic. licenses must be cleared before vendors since
|
// deterministic.
|
||||||
// licenses.vendor_id has an FK (ON DELETE RESTRICT) to vendors.
|
|
||||||
await prisma.license.deleteMany();
|
await prisma.license.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
|
||||||
@@ -66,7 +49,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",
|
||||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
vendor: "Adobe",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
expiryDate: "2027-01-15",
|
expiryDate: "2027-01-15",
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
@@ -76,7 +59,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",
|
||||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
vendor: "Adobe",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -87,7 +70,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",
|
||||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
vendor: "Microsoft",
|
||||||
seatsTotal: 5,
|
seatsTotal: 5,
|
||||||
licenseType: "perpetual",
|
licenseType: "perpetual",
|
||||||
});
|
});
|
||||||
@@ -101,7 +84,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("rejects an empty name", () => {
|
it("rejects an empty name", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "",
|
name: "",
|
||||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
vendor: "Adobe",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -112,37 +95,24 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a vendorId that is not a valid UUID", () => {
|
it("rejects an empty vendor", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendorId: "not-a-uuid",
|
vendor: "",
|
||||||
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(/vendorId/i);
|
expect(result.error).toMatch(/vendor/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",
|
||||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
vendor: "Adobe",
|
||||||
seatsTotal: 0,
|
seatsTotal: 0,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -156,7 +126,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",
|
||||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
vendor: "Adobe",
|
||||||
seatsTotal: -5,
|
seatsTotal: -5,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -170,7 +140,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",
|
||||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
vendor: "Adobe",
|
||||||
seatsTotal: 3.5,
|
seatsTotal: 3.5,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -184,7 +154,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",
|
||||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
vendor: "Adobe",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
expiryDate: "not-a-date",
|
expiryDate: "not-a-date",
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
@@ -199,7 +169,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",
|
||||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
vendor: "Adobe",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
licenseType: "trial",
|
licenseType: "trial",
|
||||||
});
|
});
|
||||||
@@ -213,7 +183,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",
|
||||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
vendor: "Adobe",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,10 +196,9 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
|
|
||||||
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",
|
||||||
vendorId: vendor.id,
|
vendor: "Adobe",
|
||||||
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.
|
||||||
@@ -240,15 +209,12 @@ describe("createLicense", () => {
|
|||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
|
|
||||||
const created = await createLicense(result.value);
|
const record = 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_id: vendor.id,
|
vendor: "Adobe",
|
||||||
seats_total: 10,
|
seats_total: 10,
|
||||||
license_type: "subscription",
|
license_type: "subscription",
|
||||||
});
|
});
|
||||||
@@ -259,55 +225,28 @@ 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",
|
||||||
vendorId: vendor.id,
|
vendor: "Microsoft",
|
||||||
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 created = await createLicense(result.value);
|
const record = 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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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",
|
||||||
vendorId: vendor1.id,
|
vendor: "Microsoft",
|
||||||
seatsTotal: 5,
|
seatsTotal: 5,
|
||||||
licenseType: "perpetual",
|
licenseType: "perpetual",
|
||||||
});
|
});
|
||||||
@@ -319,7 +258,7 @@ describe("listLicenses", () => {
|
|||||||
|
|
||||||
const second = validateCreateLicenseInput({
|
const second = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendorId: vendor2.id,
|
vendor: "Adobe",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -330,8 +269,8 @@ describe("listLicenses", () => {
|
|||||||
|
|
||||||
expect(records).toHaveLength(2);
|
expect(records).toHaveLength(2);
|
||||||
// newest first
|
// newest first
|
||||||
expect(records[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor_id: vendor2.id });
|
expect(records[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor: "Adobe" });
|
||||||
expect(records[1]).toMatchObject({ name: "Windows Server 2022", vendor_id: vendor1.id });
|
expect(records[1]).toMatchObject({ name: "Windows Server 2022", vendor: "Microsoft" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty list when there are no licenses", async () => {
|
it("returns an empty list when there are no licenses", async () => {
|
||||||
@@ -342,11 +281,10 @@ 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",
|
||||||
vendorId: vendor.id,
|
vendor: "Adobe",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
expiryDate: "2027-01-15",
|
expiryDate: "2027-01-15",
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
@@ -358,7 +296,7 @@ describe("POST /api/licenses", () => {
|
|||||||
|
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor_id: vendor.id,
|
vendor: "Adobe",
|
||||||
seats_total: 10,
|
seats_total: 10,
|
||||||
seats_used: 0,
|
seats_used: 0,
|
||||||
license_type: "subscription",
|
license_type: "subscription",
|
||||||
@@ -370,11 +308,10 @@ 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",
|
||||||
vendorId: vendor.id,
|
vendor: "Microsoft",
|
||||||
seatsTotal: 5,
|
seatsTotal: 5,
|
||||||
licenseType: "perpetual",
|
licenseType: "perpetual",
|
||||||
})
|
})
|
||||||
@@ -387,9 +324,8 @@ 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: "", vendorId: vendor.id, seatsTotal: 10, licenseType: "subscription" })
|
postRequest({ name: "", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -397,45 +333,19 @@ describe("POST /api/licenses", () => {
|
|||||||
expect(body.error).toMatch(/name/i);
|
expect(body.error).toMatch(/name/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a vendorId that is not a valid UUID", async () => {
|
it("rejects an empty vendor", async () => {
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({ name: "Adobe Creative Cloud", vendor: "", seatsTotal: 10, licenseType: "subscription" })
|
||||||
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(/vendorId/i);
|
expect(body.error).toMatch(/vendor/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", vendorId: vendor.id, seatsTotal: 0, licenseType: "subscription" })
|
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 0, licenseType: "subscription" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -444,11 +354,10 @@ 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",
|
||||||
vendorId: vendor.id,
|
vendor: "Adobe",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
expiryDate: "not-a-date",
|
expiryDate: "not-a-date",
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
@@ -461,9 +370,8 @@ 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", vendorId: vendor.id, seatsTotal: 10, licenseType: "trial" })
|
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "trial" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -498,16 +406,13 @@ 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", vendorId: vendor1.id, seatsTotal: 5, licenseType: "perpetual" })
|
postRequest({ name: "Windows Server 2022", vendor: "Microsoft", 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", vendorId: vendor2.id, seatsTotal: 10, licenseType: "subscription" })
|
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await GET();
|
const res = await GET();
|
||||||
@@ -518,8 +423,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_id: vendor2.id });
|
expect(body[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor: "Adobe" });
|
||||||
expect(body[1]).toMatchObject({ name: "Windows Server 2022", vendor_id: vendor1.id });
|
expect(body[1]).toMatchObject({ name: "Windows Server 2022", vendor: "Microsoft" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty list when there are no licenses", async () => {
|
it("returns an empty list when there are no licenses", async () => {
|
||||||
@@ -535,11 +440,10 @@ 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", vendorId: vendor.id, seatsTotal: 10, licenseType: "subscription" })
|
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
|
|||||||
@@ -1,476 +0,0 @@
|
|||||||
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" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user