Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a0d00afd8 | ||
|
|
29febf8205 | ||
|
|
3a18414d03 | ||
|
|
296a8c5d35 | ||
|
|
5af1465a91 | ||
|
|
7b47385509 | ||
|
|
e232658b3d | ||
|
|
96088421ba | ||
|
|
9e08c3f994 | ||
|
|
87e2cca019 |
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: ba
|
||||
description: Turn a stakeholder's business-level ask (e.g. "add license seat tracking", "allow rejecting a request") into a concrete, unambiguous functional spec before any code gets written. Use at the start of new work when the user states a goal or business need rather than a fully-specified task. Does not write code.
|
||||
tools: Read, Grep, Glob, Edit
|
||||
---
|
||||
|
||||
You translate business intent into an implementable spec — you don't build anything and you don't invent business rules that aren't grounded in the domain doc or an explicit stakeholder answer.
|
||||
|
||||
Rules:
|
||||
- Ground every spec in `docs/asset-license-management.md` (the source of truth for entities, fields, and state flow) and the "Trạng thái hiện tại" section of `CLAUDE.md` (what's already built). Don't contradict either without flagging the deviation explicitly.
|
||||
- Output a spec covering: entities touched, fields/validation rules, state transitions touched (if any), edge cases, and acceptance criteria (what a test should verify to call this done).
|
||||
- If the ask depends on a genuine business decision not answered by the domain doc (who can approve a Request, what happens to an `Assignment` when its `License` is retired, etc.), do not guess — surface it as an open question for the stakeholder and stop there for that part of the spec.
|
||||
- If the ask is small and already matches an established pattern exactly (e.g. "add another entity like `Employee`"), say so plainly rather than padding out a spec for its own sake.
|
||||
- When the stakeholder resolves an open question, you may append the clarified rule back into `docs/asset-license-management.md` (extend its existing structure, don't duplicate) so it isn't re-litigated on the next task.
|
||||
- `pm` may send a technical-gap question back to you (a detail your spec left unstated that changes how tasks should be scoped). Treat it the same as a stakeholder open question: resolve it if the domain doc already answers it, otherwise escalate to the stakeholder — then amend the spec and `docs/asset-license-management.md` so the answer persists.
|
||||
- Do not write application code and do not break work into implementation tasks — that's `pm`'s job, next in the pipeline.
|
||||
@@ -4,7 +4,7 @@ description: Review a finished diff/feature in asset-license-mgmt for correctnes
|
||||
tools: Read, Grep, Glob, Bash
|
||||
---
|
||||
|
||||
Review the diff against the domain rules in `../processes/asset-license-management.md`. Focus on:
|
||||
Review the diff against the domain rules in `docs/asset-license-management.md`. Focus on:
|
||||
|
||||
- **State machine correctness** — Request should only move Draft → Pending Approval → Approved → Ordered → Received → Delivered → Closed, with Rejected/Cancelled as the only side branches. Flag any code path that skips or reverses a state without justification.
|
||||
- **Authorization** — who is allowed to approve a Request, mark a Receipt, or complete a Delivery. Flag missing or client-side-only checks.
|
||||
|
||||
@@ -11,6 +11,6 @@ Rules:
|
||||
- You may run the specific test(s) you just wrote as a sanity check, but do not run the full suite and declare overall success — that verdict belongs to `test-runner`, spawned separately.
|
||||
- If the task is ambiguous on implementation details, make the smallest reasonable assumption and state it in your report. If it's ambiguous on something that affects the Request state machine, approval permissions, or data integrity (e.g. who can approve, what counts as a valid state transition), stop and ask rather than guessing.
|
||||
- A note in CLAUDE.md marked "đề xuất" / "proposed" / "not finalized" (e.g. tech stack) is not a decision — it's a placeholder. The first task that would lock it in by writing real code against it (choosing the framework, DB, test runner) must stop and confirm with the user before proceeding, even though it "looks like" an implementation detail. Once the user has confirmed it once (in this conversation or recorded in CLAUDE.md as decided), later tasks can build on it without re-asking.
|
||||
- Follow the data model and state flow defined in `../processes/asset-license-management.md` — don't invent a different shape for entities like `Request`, `AssetItem`, `License`, `Assignment` without flagging the deviation.
|
||||
- Follow the data model and state flow defined in `docs/asset-license-management.md` — don't invent a different shape for entities like `Request`, `AssetItem`, `License`, `Assignment` without flagging the deviation.
|
||||
|
||||
End every report with three sections: **Changed** (files touched), **Assumed** (judgment calls made), **Out of scope** (what you deliberately did not do).
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: pm
|
||||
description: Break a spec (from ba) or a stated priority into an ordered sequence of concretely-scoped implementation tasks, sized for one feature-implementer pass each. Use after ba has produced a spec, or directly for well-understood backlog items. Does not write code or make business-rule decisions.
|
||||
tools: Read, Grep, Glob, Edit
|
||||
---
|
||||
|
||||
You sequence work — you don't build anything and you don't resolve business-rule ambiguity.
|
||||
|
||||
Rules:
|
||||
- Read `CLAUDE.md`'s "Trạng thái hiện tại" and "Việc còn dở" first to know what's already built and what's outstanding. Don't resequence or duplicate finished work.
|
||||
- Break the spec into tasks that follow the repo's established layering convention for new work (see "Coding conventions" in `CLAUDE.md` — for a new entity: Prisma model+migration → validation/CRUD lib → API routes → tests). Each task must be sized so one `feature-implementer` pass can complete it, tests included.
|
||||
- Order tasks by real dependency (a task needing a FK to an entity that doesn't exist yet is blocked by that entity's task) and by what unblocks the most subsequent work — not by arbitrary preference.
|
||||
- Before finalizing the task list: if the spec is missing a technical detail whose resolution could change business behavior (an unstated field, an unclear validation rule, an unspecified relation between entities), do not fill the gap yourself and do not silently assume — send the question back to `ba` to amend the spec first. Purely technical implementation choices with no business-behavior impact (e.g. how to name a helper, which layer a check lives in) are yours to decide without looping back.
|
||||
- Split tasks so they map cleanly onto the repo's commit convention (`CLAUDE.md` → "Git / commit convention": model+migration / lib / API routes / tests as separate commits per entity) — the more precisely a task's scope matches one commit, the better.
|
||||
- Output the ordered task list back to whoever dispatched you (the orchestrating session, not the end user directly) — one line per task, each independently dispatchable to `feature-implementer`.
|
||||
- If a task in the sequence would touch the Request state machine, approval permissions, or anything `feature-implementer`'s own rules say to stop and ask about, flag it as needing stakeholder confirmation before implementation rather than sequencing it silently.
|
||||
- Do not implement anything yourself, and do not resolve business-rule ambiguity — kick that back to `ba`/the stakeholder.
|
||||
- Once a sequencing/priority decision is confirmed, you may update the "Việc còn dở" list in `CLAUDE.md` to reflect it, keeping that section the single source of truth for "what's next."
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env*.local
|
||||
npm-debug.log*
|
||||
*.tsbuildinfo
|
||||
@@ -14,3 +14,8 @@ DATABASE_URL=postgres://asset_license_mgmt:change-me-local-dev@db:5432/asset_lic
|
||||
# .env/.env.local) or `openssl rand -base64 33`, then copy the value here.
|
||||
# Do NOT reuse this placeholder — must be a real per-environment secret.
|
||||
AUTH_SECRET=change-me-generate-a-real-secret
|
||||
|
||||
# Required for `next start` behind a reverse proxy (NPM) — without it,
|
||||
# Auth.js rejects requests with UntrustedHost since the Host header comes
|
||||
# from the proxy, not a browser connecting directly. Not needed for `next dev`.
|
||||
AUTH_TRUST_HOST=true
|
||||
|
||||
@@ -53,25 +53,30 @@ Project này được dùng từ **nhiều máy khác nhau** (máy Windows công
|
||||
- `npm install` tự chạy `prisma generate` qua hook `postinstall` (xem `package.json`) — không cần gọi tay trừ khi thay đổi `schema.prisma` mà không chạy `npm install` lại.
|
||||
- `pg` vẫn còn trong `devDependencies` — **chỉ** dùng cho hạ tầng test (`tests/setup.ts` tạo/xoá throwaway DB), không phải data access layer của app (đó là Prisma). Đừng nhầm là app vẫn dùng raw SQL.
|
||||
|
||||
### Lưu ý về việc gọi custom agent (feature-implementer/test-runner/code-reviewer)
|
||||
### Lưu ý về việc gọi custom agent (ba/pm/feature-implementer/test-runner/code-reviewer)
|
||||
|
||||
Các agent định nghĩa ở `.claude/agents/` trong thư mục **này** (`asset-license-mgmt/`) chỉ được Claude Code nhận diện làm `subagent_type` nếu phiên Claude Code được mở với working directory **là chính thư mục này** (hoặc thư mục con của nó) — Claude Code quét `.claude/agents/` từ cwd đi lên tới repo root, **không quét xuống** thư mục con. Nếu mở Claude Code từ `ubisoft/` (thư mục cha, gộp nhiều project), 3 agent này sẽ báo "not found". Cách đúng: `cd` vào `asset-license-mgmt/` rồi mới chạy `claude` khi làm việc trên project này.
|
||||
Các agent định nghĩa ở `.claude/agents/` trong thư mục **này** (`asset-license-mgmt/`) chỉ được Claude Code nhận diện làm `subagent_type` nếu phiên Claude Code được mở với working directory **là chính thư mục này** (hoặc thư mục con của nó) — Claude Code quét `.claude/agents/` từ cwd đi lên tới repo root, **không quét xuống** thư mục con. Nếu mở Claude Code từ `ubisoft/` (thư mục cha, gộp nhiều project), các agent này sẽ báo "not found". Cách đúng: `cd` vào `asset-license-mgmt/` rồi mới chạy `claude` khi làm việc trên project này.
|
||||
|
||||
## Multi-agent dev workflow
|
||||
|
||||
Mục tiêu: không để một agent vừa viết code vừa tự chấm bài mình — tách vai trò để bug bị bắt lại thay vì được tự "cho qua".
|
||||
Mục tiêu: stakeholder (user) chỉ cần đưa ra mục tiêu/ưu tiên ở mức nghiệp vụ — không cần tự chia nhỏ task hay chọn giữa các phương án kỹ thuật. Đồng thời không để một agent vừa viết code vừa tự chấm bài mình — tách vai trò để bug bị bắt lại thay vì được tự "cho qua".
|
||||
|
||||
Với mỗi task cụ thể (vd "thêm API tạo Request", "màn hình bàn giao asset"):
|
||||
Với mỗi mục tiêu nghiệp vụ stakeholder đưa ra (vd "thêm tracking seat cho License", "cho phép reject Request"):
|
||||
|
||||
1. **`feature-implementer`** — nhận đúng 1 task, viết code + test đi kèm trong cùng lượt. Không tự tuyên bố "đã pass" — chỉ báo cáo đã đổi gì, giả định gì, và cái gì cố tình để ngoài scope.
|
||||
2. **`test-runner`** — spawn **riêng**, không phải cùng agent vừa code. Chạy toàn bộ test suite + lint + type-check, báo lỗi cụ thể kèm file:line. Không tự sửa code.
|
||||
3. Nếu fail: đưa report của test-runner làm input cho một lượt `feature-implementer` mới để fix. Lặp lại bước 1–2 tới khi xanh hết.
|
||||
4. **`code-reviewer`** (optional, dùng cho thay đổi lớn/rủi ro — vd đổi state machine của Request, logic seat-count license) — review diff cuối cùng trước khi commit, tìm bug logic/security, không tự sửa.
|
||||
0. **`ba`** — chuyển yêu cầu ở mức business thành spec cụ thể (field, rule, edge case, acceptance criteria), bám theo `docs/asset-license-management.md`, và note lại spec/quyết định đã chốt vào chính doc đó. Nếu có quyết định nghiệp vụ chưa rõ (ai được approve, xử lý `Assignment` khi `License` bị retire...) → dừng lại hỏi thẳng stakeholder, không tự đoán. Bỏ qua bước này nếu yêu cầu đã đủ nhỏ/rõ (vd 1 bug fix cụ thể).
|
||||
1. **`pm`** — nhận spec từ `ba` (hoặc thẳng backlog đã rõ), chia thành danh sách task cụ thể theo đúng thứ tự lớp kỹ thuật chuẩn (model+migration → lib → API → test), sắp theo phụ thuộc, map mỗi task tới đúng 1 commit theo convention ở "Git / commit convention" — tách càng cụ thể càng tốt. Nếu spec thiếu chi tiết kỹ thuật mà việc tự quyết có thể đổi hành vi nghiệp vụ, `pm` hỏi ngược lại `ba` để bổ sung spec trước khi chốt task, thay vì tự đoán rồi chia task luôn. Không tự code, không tự quyết nghiệp vụ.
|
||||
2. **`feature-implementer`** — nhận đúng 1 task từ `pm`, viết code + test đi kèm trong cùng lượt. Không tự tuyên bố "đã pass" — chỉ báo cáo đã đổi gì, giả định gì, và cái gì cố tình để ngoài scope.
|
||||
3. **`test-runner`** — spawn **riêng**, không phải cùng agent vừa code. Chạy toàn bộ test suite + lint + type-check, báo lỗi cụ thể kèm file:line. Không tự sửa code.
|
||||
4. Nếu fail: đưa report của test-runner làm input cho một lượt `feature-implementer` mới để fix. Lặp lại bước 2–3 tới khi xanh hết.
|
||||
5. **`code-reviewer`** (optional, dùng cho thay đổi lớn/rủi ro — vd đổi state machine của Request, logic seat-count license) — review diff cuối cùng trước khi commit, tìm bug logic/security, không tự sửa.
|
||||
|
||||
### Quy tắc cứng
|
||||
- `ba`/`pm` luôn chạy trước khi có task cụ thể cho `feature-implementer`, trừ khi task đã đủ nhỏ/rõ ràng (bug fix cụ thể, không phải feature mới) — trường hợp đó có thể giao thẳng cho `feature-implementer`.
|
||||
- Implementer và test-runner luôn là 2 lượt spawn riêng biệt cho cùng 1 task — không gộp.
|
||||
- test-runner không được tự sửa file, kể cả khi lỗi trông "hiển nhiên".
|
||||
- Nếu implementer thấy task mơ hồ, chọn giả định nhỏ nhất hợp lý và ghi rõ trong report thay vì dừng lại hỏi — nhưng nếu ảnh hưởng luồng trạng thái Request/quyền approval thì phải hỏi trước khi code.
|
||||
- `ba`/`pm` không tự quyết nghiệp vụ mơ hồ — luôn đẩy ngược lại stakeholder thay vì đoán.
|
||||
- `pm` gặp gap kỹ thuật ảnh hưởng nghiệp vụ (field/rule/quan hệ chưa nêu trong spec) → hỏi ngược lại `ba` (không tự bịa), `ba` trả lời bằng cách tra `docs/asset-license-management.md` hoặc escalate tiếp lên stakeholder nếu doc cũng chưa có câu trả lời.
|
||||
|
||||
## Trạng thái hiện tại
|
||||
|
||||
@@ -87,7 +92,7 @@ Với mỗi task cụ thể (vd "thêm API tạo Request", "màn hình bàn giao
|
||||
- **Auth (login nội bộ, không phải SSO công ty) xong (2026-07-31)**: `User` Prisma model (`prisma/schema.prisma`), `src/lib/users.ts` (Zod + `createUser()` + `verifyUserCredentials()`, bcryptjs hash), `scripts/create-user.ts` (tạo account bằng CLI, không có self-registration), Auth.js v5 (`next-auth@^5.0.0-beta.32`, Credentials provider, JWT session, không dùng `@auth/prisma-adapter` vì không cần OAuth/DB session) ở `src/auth.ts` + `src/app/api/auth/[...nextauth]/route.ts`, trang login `src/app/login/page.tsx`. Permission model **flat** (bất kỳ User nào login cũng làm mọi thứ được — quyết định đã chốt, không có `role`). **CONFIRMED qua verify độc lập** (test-runner + security review): 16/16 test pass (6 cũ + 10 mới) trên Postgres thật, tsc/lint/build sạch, không lộ password, bcrypt compare đúng chuẩn constant-time, `AUTH_SECRET` fail-closed thật khi thiếu (đã test thực tế, không fallback giá trị đoán được).
|
||||
- **Finding nhỏ (chưa block, cần fix sau)**: timing side-channel trong `verifyUserCredentials` — trả về sớm khi email không tồn tại (bỏ qua bcrypt compare), nên request với email sai vs password sai có thời gian phản hồi khác nhau chút, có thể dò được email nào tồn tại. Fix: luôn chạy `bcrypt.compare()` kể cả khi không tìm thấy user (so với 1 hash giả cố định).
|
||||
- **Finding nhỏ (chấp nhận được, cần track)**: `scripts/create-user.ts --password <giá trị>` lộ password vào shell history/process list — chấp nhận vì chỉ IT admin tự chạy tay, không phải self-registration công khai, nhưng nên cải thiện sau (đọc từ prompt/stdin thay vì argv).
|
||||
- **Note triển khai (chưa cần ngay, nhớ trước khi deploy)**: chạy `next start` (production) cần thêm `AUTH_TRUST_HOST=true` hoặc `AUTH_URL` trong env, nếu không sẽ gặp lỗi `UntrustedHost` — chưa thêm vào `.env.example`, cần làm trước khi deploy thật ở nhà.
|
||||
- **Note triển khai**: chạy `next start` (production) cần thêm `AUTH_TRUST_HOST=true` hoặc `AUTH_URL` trong env, nếu không sẽ gặp lỗi `UntrustedHost` — đã thêm vào `.env.example`/`.env` khi deploy thật (xem bullet deploy bên dưới).
|
||||
- **Route-gating xong (2026-07-31)**: `src/middleware.ts` (protect mọi page route theo default-deny, redirect `/login` nếu chưa đăng nhập, trừ `/login` chính nó — tránh vòng lặp redirect), `src/auth.config.ts` (config Auth.js edge-safe, tách ra khỏi `src/auth.ts` — **không** có Credentials provider vì `authorize()` gọi `verifyUserCredentials()` kéo theo `bcryptjs`, là Node API không chạy được trên Edge Runtime mà `middleware.ts` dùng; `src/middleware.ts` tự tạo instance `NextAuth(authConfig)` riêng, nhẹ, chỉ để decode JWT session cookie — xác nhận qua build thật: trước khi tách, `next build` báo warning bcryptjs/jose bundle vào Edge Runtime (middleware 115kB); sau khi tách, build sạch, middleware còn 78.9kB). `src/auth.ts` giờ spread `authConfig` + thêm Credentials provider thật, dùng cho API route/Server Component (chạy Node runtime).
|
||||
- `config.matcher` của middleware loại trừ toàn bộ `/api` (không chỉ `/api/auth`) — API route tự check `auth()` lấy session ngay trong handler (không dựa hoàn toàn vào middleware, theo đúng khuyến nghị chính thức của Auth.js: verify session càng gần chỗ fetch data càng tốt). `/api/requests` (`GET`/`POST`) đã thêm check `auth()`, trả `401 {"error":"Unauthorized"}` nếu chưa login — xem `src/app/api/requests/route.ts`.
|
||||
- `/` (home page placeholder) **có** yêu cầu login — chọn theo nguyên tắc "mặc định chặn hết, allowlist rõ ràng cho cái không cần" thay vì opt-in dễ quên route mới.
|
||||
@@ -95,21 +100,28 @@ Với mỗi task cụ thể (vd "thêm API tạo Request", "màn hình bàn giao
|
||||
- **`Employee` entity xong + `Request.employee_id` retrofit sang FK thật (2026-07-31)**: `Employee` Prisma model (`prisma/schema.prisma` — `name`, `department` (string đơn giản, chưa tách entity riêng), `managerId` self-relation nullable), `src/lib/employees.ts` (Zod + CRUD, theo đúng pattern chuẩn ở "Coding conventions"), `src/app/api/employees/route.ts` (`POST`/`GET`, gate `auth()` như `/api/requests`), `tests/employees.test.ts` (11 test, Postgres thật). `Request.employeeId` — trước đây là `String` tự do, không có ràng buộc — giờ là FK thật tới `Employee` (`@relation`, migration `20260731075828_relate_request_to_employee`). **Đây là thay đổi hành vi có chủ ý tới API `Request` đã ship trước đó**: `employee_id` trong body `POST /api/requests` giờ **bắt buộc là UUID hợp lệ** (Zod `.uuid()`, không còn nhận string tuỳ ý như `"emp-1"`), và phải **trỏ tới một `Employee` có thật** — nếu không, `createRequest()` bắt lỗi FK violation của Prisma (`P2003`) và trả `400 {"error": "employee_id does not reference an existing employee"}` thay vì lỗi 500 thô. `tests/requests.test.ts` đã cập nhật: mọi test tạo `Request` giờ tạo `Employee` thật trước qua helper `createTestEmployee()` rồi dùng `.id` thật, có thêm 2 test mới cho đúng 2 case bị FK chặn (UUID sai format, UUID đúng format nhưng không tồn tại).
|
||||
- **Bug hạ tầng test phát hiện + fix trong lượt này**: `tests/setup.ts` tạo DB Postgres tạm riêng cho mỗi file test bằng cách ghi đè `process.env.DATABASE_URL` trong `beforeAll` — cách này **không an toàn** khi Vitest chạy nhiều file test song song (mặc định): timing giữa `beforeAll`/query của 2 file có thể chồng nhau khiến cả 2 cùng thao tác nhầm lên 1 DB tạm. Trước đây không lộ ra vì chưa có 2 file test nào cùng đụng bảng có FK với nhau; lộ ra ngay khi `Request` có FK thật tới `Employee` (`tests/employees.test.ts` và `tests/requests.test.ts` xoá/đọc nhầm dữ liệu của nhau, lặp lại 100% mỗi lần chạy). Fix: `vitest.config.ts` thêm `fileParallelism: false` (chạy tuần tự từng file) — **CONFIRMED**: 31/31 test pass ổn định qua `npm test` (11 employees + 10 requests + 10 auth), `npx tsc --noEmit`/`npm run lint`/`npm run build` đều sạch.
|
||||
- **`AssetItem` entity xong (2026-07-31)**: `AssetItem` Prisma model + enum `AssetItemStatus` (`in_stock`/`assigned`/`repair`/`retired`, default `in_stock`) — `serialNumber` (unique), `model`, `purchaseDate`, `warrantyEndDate` (nullable — không phải asset nào cũng biết hạn bảo hành lúc nhập), migration `20260731090347_add_asset_item`. `src/lib/assets.ts` (Zod + CRUD, theo đúng pattern chuẩn ở "Coding conventions", bắt lỗi Prisma `P2002` khi `serialNumber` trùng và trả `400 {"error": "serialNumber already exists"}` thay vì lỗi 500 thô), `src/app/api/assets/route.ts` (`POST`/`GET`, gate `auth()` như `/api/employees`), `tests/assets.test.ts` (11 test, Postgres thật). Asset mới luôn tạo ở status `in_stock` (không nhận status từ client lúc tạo, như `Request` luôn tạo `Draft`) — các state khác (assigned/repair/retired) sẽ set qua thao tác sau, chưa có endpoint riêng. Chưa có quan hệ tới `Vendor`/`Assignment` (2 entity đó chưa build). **CONFIRMED qua test-runner độc lập**: 42/42 test pass (11 assets + 11 employees + 10 requests + 10 auth) trên Postgres thật, `npx tsc --noEmit`/`npm run lint`/`npm run build` đều sạch.
|
||||
- **Deploy lên homelab xong (2026-08-02)**: `Dockerfile` (single-stage `node:20-alpine`, `npm ci` + `npm run build` + `npm start`) và service `app` mới trong `docker-compose.yml` (port `3002:3000`, cùng network `asset-license-mgmt` với `db` để resolve hostname `db`). **Gotcha đã gặp + fix**: `node:20-alpine` không có `libssl` sẵn → Prisma không detect được OpenSSL lúc `npm ci` (postinstall chạy `prisma generate`), tự chọn nhầm engine binary (`openssl-1.1.x`) — build vẫn xanh (route API là dynamic, không chạm Prisma lúc build) nhưng **crash lúc runtime** khi query thật. Fix: `RUN apk add --no-cache openssl` trong `Dockerfile` **trước** `npm ci`. Đã verify bằng cách gọi `prisma.employee.count()` thật bên trong container sau khi rebuild — pass. Expose ra internet qua `ubi.toanhoang.work` theo đúng flow `add-service` skill ở `/opt/homelab/CLAUDE.md`: subdomain qua `cloudflare-ddns-ubi`, NPM proxy host (`10.8.2.3:3002`, SSL forced) + Let's Encrypt, Unbound Host Override cho LAN. `AUTH_TRUST_HOST=true` thêm vào `.env`/`.env.example` (đóng TODO cũ). **Chưa có user nào trong DB** — chạy `docker compose exec app npm run user:create` để tạo account đầu tiên trước khi ai có thể login thật.
|
||||
|
||||
- **`License` entity xong (2026-08-02)**: `License` Prisma model + enum `LicenseType` (`perpetual`/`subscription`) — `name`, `vendor` (string đơn giản, chưa tách entity `Vendor` riêng, theo đúng precedent `Employee.department`), `seatsTotal` (bắt buộc `> 0`), `seatsUsed` (default `0`, client không set được lúc tạo — giống `AssetItem.status` luôn tạo `in_stock`), `expiryDate` (nullable — perpetual không hết hạn), migration `20260802123955_add_license`. `src/lib/licenses.ts` (Zod + CRUD), `src/app/api/licenses/route.ts` (`POST`/`GET`, gate `auth()` như các entity khác), `tests/licenses.test.ts` (27 test, cả lib-level lẫn route-level, có thêm test malformed JSON/non-object body — đóng luôn 1 phần gap coverage cũ, nhưng chỉ cho `licenses`, chưa retrofit lại cho `assets`/`employees`/`requests`). **CONFIRMED qua test-runner độc lập**: 69/69 test pass, tsc/lint sạch. **Review qua `code-reviewer` (trigger: seat-count logic)**: không có bug chặn, nhưng có finding cần nhớ — `createLicense()` trả `Promise<LicenseRecord>` trần (không theo pattern `{ok,value}|{ok,error}` như `createAssetItem()`); vô hại bây giờ vì `License` chưa có unique constraint nào, nhưng nếu sau này thêm `@@unique` (vd trên `name`+`vendor`) mà không đồng thời thêm xử lý lỗi `P2002` ở `createLicense()`, request trùng sẽ trả 500 thô thay vì `400` sạch như các entity khác. Chưa fix vì hiện tại xử lý lỗi cho constraint không tồn tại là dư thừa — cần nhớ làm cùng lúc nếu/khi thêm unique constraint.
|
||||
|
||||
## Việc còn dở
|
||||
|
||||
- `fileParallelism: false` (fix race condition test-infra) là fix đúng nhưng "cùn" — chặn song song **toàn bộ** suite thay vì chỉ fix gốc rễ (`tests/setup.ts` ghi đè `process.env.DATABASE_URL` toàn cục thay vì truyền connection string riêng cho từng Prisma Client instance). Test suite sẽ chạy chậm dần tuyến tính khi thêm entity mới (`AssetItem`, `License`...). Cân nhắc refactor `tests/setup.ts` để mỗi file tự có Prisma Client riêng trỏ đúng DB tạm của mình, rồi bật lại parallelism — chưa gấp ở quy mô hiện tại (3 file, ~15s).
|
||||
- Fix timing side-channel ở `verifyUserCredentials` (xem note ở "Trạng thái hiện tại").
|
||||
- Cải thiện `scripts/create-user.ts` để password không lộ qua CLI arg (đọc từ stdin/prompt thay vì `--password`).
|
||||
- Thêm `AUTH_TRUST_HOST`/`AUTH_URL` vào `.env.example` trước khi deploy production.
|
||||
- Bump `vitest` lên 4.x (fix advisory critical GHSA-5xrq-8626-4rwp — hiện không khai thác được vì project không chạy `vitest --ui`, nhưng nên dọn khi có breaking-change window) — không khẩn cấp.
|
||||
- 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).
|
||||
- `Button` (shadcn) không `forwardRef`, React đang pin 18 — dormant risk, xem note ở "Trạng thái hiện tại".
|
||||
- Các entity còn lại: `License`, `PurchaseOrder`, `Receipt`, `Assignment` (`AssetItem` đã xong 2026-07-31, xem "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ẽ):**
|
||||
- **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ần stakeholder trả lời trước khi code Lane C (không tự đoán)**: (a) `createAssignment()` có cần chặn nếu `AssetItem.status` hiện tại **không phải** `in_stock` không (vd không cho assign 1 item đang `repair`/`retired`)? Spec hiện chỉ mô tả status **tự đổi theo sau** khi assign/return, không nói rõ có precondition chặn assign hay không — nếu không chặn, 1 asset "retired" hoặc "repair" (miễn chưa có `Assignment` active nào) vẫn assign được, có vẻ sai nghiệp vụ nhưng chưa được xác nhận rõ. (b) REST shape cho "update" (PO nhận hàng, Assignment return/transfer) — đề xuất ở trên (`PATCH .../[id]`) là suy đoán hợp lý theo REST convention chuẩn, chưa có tiền lệ nào trong codebase để đối chiếu, nên xác nhận với `pm` trước khi `feature-implementer` tự bịa shape khác nhau giữa `PurchaseOrder` và `Assignment`.
|
||||
- `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
|
||||
- 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)
|
||||
|
||||
---
|
||||
Cập nhật lần cuối: 2026-07-31 (AssetItem entity)
|
||||
Cập nhật lần cuối: 2026-08-02 (deploy lên homelab ubi.toanhoang.work, License entity, task sequencing cho Vendor/PurchaseOrder/Assignment/AssetCategoryAttributeDef)
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Prisma's engine detection needs libssl present at `npm ci` time (when
|
||||
# `postinstall` runs `prisma generate`) — without it, Prisma can't detect
|
||||
# the OpenSSL version and silently picks the wrong engine binary, which
|
||||
# only fails later at runtime when a query actually tries to run.
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "start"]
|
||||
@@ -1,4 +1,17 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
container_name: asset-license-mgmt-app
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3002:3000"
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- asset-license-mgmt
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: asset-license-mgmt-db
|
||||
|
||||
@@ -23,14 +23,17 @@ Theo dõi toàn bộ vòng đời tài sản/license: **Request → Approval →
|
||||
| Entity | Ý nghĩa |
|
||||
|---|---|
|
||||
| `Employee` | id, tên, phòng ban, manager |
|
||||
| `Vendor` | nhà cung cấp |
|
||||
| `AssetItem` | hardware — serial number, model, ngày mua, bảo hành, trạng thái (in stock / assigned / repair / retired) |
|
||||
| `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 |
|
||||
| `PurchaseOrder` | gộp nhiều Request đã duyệt, vendor, ngày đặt, ngày dự kiến nhận |
|
||||
| `Receipt` | nhận hàng — đối chiếu PO, ngày nhận, người nhận |
|
||||
| `Assignment` | gán AssetItem/License cho Employee — ngày giao, ngày trả, biên bản ký nhận |
|
||||
| `Vendor` | id, `name`, `contactEmail`, `contactPhone`, `address`, `taxCode` (nullable — mã số thuế VN), `notes` (nullable) — nhà cung cấp/reseller mua hàng qua, **khác** `AssetModel.manufacturer` (hãng sản xuất/OEM). Xem "Vendor" bên dưới |
|
||||
| `AssetItem` | hardware **high-value** (serial hóa bắt buộc) — serial number, model (FK → `AssetModel`, bắt buộc — không còn string tự do), ngày mua, bảo hành, trạng thái (in stock / assigned / repair / retired), `purchaseOrderId` (FK → `PurchaseOrder`, nullable — chiều cung, xem "PurchaseOrder" bên dưới). Category không còn gán trực tiếp trên `AssetItem` — suy ra qua `AssetModel.categoryId`. Xem "Phân loại tài sản" bên dưới |
|
||||
| `License` | phần mềm — tên, `vendorId` (FK → `Vendor`, retrofit từ string tự do — xem "Vendor" bên dưới), tổng seat, seat đã dùng, ngày hết hạn, loại (perpetual/subscription), `purchaseOrderId` (FK → `PurchaseOrder`, nullable — chiều cung, xem "PurchaseOrder" bên dưới) |
|
||||
| `Request` | người yêu cầu, loại (asset/license), lý do, trạng thái, `purchaseOrderId` (FK → `PurchaseOrder`, nullable — chiều cầu, xem "PurchaseOrder" bên dưới) — **chỉ áp dụng cho asset high-value/license**, không dùng cho consumable (xem "Phân loại tài sản" bên dưới) |
|
||||
| `PurchaseOrder` | 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 |
|
||||
| `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 |
|
||||
| `Request.servicenow_ticket` | field text, nhập tay số ticket ServiceNow liên quan (không tích hợp API ServiceNow ngay — volume thấp, nhập tay đủ dùng; để ngỏ cho auto-sync sau nếu cần) |
|
||||
| `AssetCategory` | id, tên (vd "Laptop", "Cáp HDMI"), nhóm giá trị cố định (`high_value` \| `low_value`) — danh mục category cụ thể do IT admin tự định nghĩa, không hardcode. Xem "Phân loại tài sản" bên dưới |
|
||||
| `AssetModel` | id, category (FK → `AssetCategory`, bắt buộc, phải thuộc nhóm `high_value`), `manufacturer` (vd "Dell"), `name` (vd "Latitude 5420"), `specs` (JSONB — giá trị thật của các thuộc tính động, xem `AssetCategoryAttributeDef`) — tách riêng 2 field `manufacturer`/`name`, không gộp chung 1 string, để filter/report theo hãng. Nằm giữa `AssetCategory` và `AssetItem` (1 model có nhiều `AssetItem` serial khác nhau). Xem "Phân loại tài sản" bên dưới |
|
||||
| `AssetCategoryAttributeDef` | id, `categoryId` (FK → `AssetCategory`), `key` (slug, vd `ram_gb`), `label` (tên hiển thị, vd "RAM (GB)"), `dataType` (enum đóng: `string`/`number`/`boolean`/`date`), `required` (bool), `sortOrder` (int) — định nghĩa thuộc tính riêng theo từng category, giá trị thật nằm ở `AssetModel.specs`. Unique `(categoryId, key)`. Xem "Phân loại tài sản" bên dưới |
|
||||
| `ConsumableItem` | phụ kiện **low-value/consumable** — tên, category (bắt buộc, phải thuộc nhóm `low_value`, trỏ thẳng `AssetCategory` — **không** qua `AssetModel`, khái niệm "model" chỉ áp dụng cho asset serial hóa), số lượng tồn kho (`quantityOnHand`). **KHÔNG serial hóa, KHÔNG gắn Assignment cá nhân** — chỉ track theo số lượng. Xem "Phân loại tài sản" bên dưới |
|
||||
|
||||
## Luồng trạng thái Request
|
||||
|
||||
@@ -40,6 +43,77 @@ Draft → Pending Approval → Approved → Ordered → Received → Delivered
|
||||
Rejected / Cancelled
|
||||
```
|
||||
|
||||
## Phân loại tài sản: High-value vs Low-value/Consumable (chốt 2026-08-02)
|
||||
|
||||
**Quyết định đã chốt với stakeholder:** tài sản được phân nhóm theo **category cố định**, KHÔNG theo ngưỡng giá tiền. Mỗi `AssetCategory` thuộc đúng 1 trong 2 nhóm giá trị (`high_value` hoặc `low_value`), category cụ thể do IT admin tự định nghĩa/mở rộng — danh sách dưới đây chỉ là **ví dụ minh hoạ, không phải danh sách đóng**:
|
||||
|
||||
- Nhóm `high_value` → đi qua luồng `AssetItem` hiện có nguyên vẹn (serial hóa bắt buộc, track cá nhân qua `Assignment`, đi qua state machine đầy đủ của `Request`). Ví dụ category: Laptop, Màn hình, Máy in, Điện thoại công ty.
|
||||
- Nhóm `low_value` (consumable) → entity mới `ConsumableItem`, **không serial hóa**, chỉ track theo số lượng tồn kho (`quantityOnHand`), **không gắn `Assignment`** cho 1 người cụ thể. Ví dụ category: Cáp (HDMI/USB-C/LAN), Chuột, Bàn phím, Túi đựng laptop, Sạc dự phòng.
|
||||
|
||||
**Cập nhật 2026-08-02 (2): thêm phân cấp `AssetModel` giữa `AssetCategory` và `AssetItem`.** Lý do: nhiều model khác nhau trong cùng 1 category (vd category "Laptop" có nhiều model "Dell Latitude 5420", "MacBook Pro 14"...), cần report/thống kê theo model (vd "đang có bao nhiêu Dell Latitude 5420"), tránh lỗi gõ tay khi model là string tự do như trước. Phân cấp: `AssetCategory` ("Laptop") → `AssetModel` ("Dell Latitude 5420") → từng `AssetItem` (1 serial cụ thể thuộc model đó). `AssetModel.manufacturer`/`AssetModel.name` tách riêng (không gộp 1 string) để filter/report riêng theo hãng. Khái niệm "model" chỉ có ý nghĩa cho asset serial hóa (`high_value`) — `ConsumableItem` không có model, vẫn trỏ thẳng `AssetCategory` (`low_value`) như cũ.
|
||||
|
||||
**Cập nhật 2026-08-02 (3): thêm `AssetCategoryAttributeDef` — thuộc tính đặc thù theo từng category.** Lý do: từng category có bộ thuộc tính riêng cần track (vd RAM/CPU cho category "Laptop", độ phân giải cho "Màn hình") mà không phải asset high-value nào cũng giống nhau — cần định nghĩa động theo category, không hardcode cột cứng cho từng loại thuộc tính.
|
||||
|
||||
- Entity mới `AssetCategoryAttributeDef`: `categoryId` (FK → `AssetCategory`), `key` (slug, vd `ram_gb`), `label` (tên hiển thị, vd "RAM (GB)"), `dataType` (enum đóng: `string`/`number`/`boolean`/`date` — đây là tập giá trị kỹ thuật cố định do dev định nghĩa, **khác** với `AssetCategory` là tập mở IT admin tự mở rộng), `required` (bool), `sortOrder` (int, thứ tự hiển thị trên form). Unique `(categoryId, key)`.
|
||||
- Mỗi `AssetCategory` có bộ attribute **riêng, độc lập** — cố tình **không** phải thư viện thuộc tính dùng chung giữa các category. Nếu 2 category cùng muốn 1 thuộc tính kiểu "khối lượng (kg)", mỗi category tự tạo 1 row `AssetCategoryAttributeDef` riêng — trùng tên `key` giữa 2 category là bình thường và được chấp nhận, đây là 2 row không liên quan tới nhau. Lý do: ở quy mô hiện tại (ít category), thiết kế thư viện thuộc tính dùng chung (tách riêng bảng `AttributeDef` + bảng join `CategoryAttribute`) cộng thêm độ phức tạp thật (thêm 1 bảng, UX phải chọn "gắn thuộc tính có sẵn hay tạo mới", sửa 1 def ảnh hưởng dây chuyền nhiều category) để đổi lấy lợi ích (sửa 1 chỗ áp dụng khắp nơi, query cross-category an toàn) mà hiện chưa cần tới — stakeholder đã từ chối rõ ràng thiết kế dùng chung để giữ đơn giản.
|
||||
- Giá trị thật nằm ở `AssetModel.specs` (cột JSONB) — **không** nằm trên `AssetItem`, vì đây là thuộc tính theo spec-sheet dùng chung cho mọi serial thuộc cùng 1 model (mọi `AssetItem` "Dell Latitude 5420" đều cùng RAM), không phải dữ liệu riêng theo từng instance. Validate động lúc ghi: app build Zod schema runtime từ các `AssetCategoryAttributeDef` thuộc category của model đó (map `dataType` → `z.string()`/`z.number()`/`z.boolean()`/`z.coerce.date()`), từ chối key lạ chưa được định nghĩa cho category, bắt buộc các key có `required = true` phải có mặt.
|
||||
- Known gap chấp nhận được, chưa chặn: nếu admin sửa/xoá 1 `AssetCategoryAttributeDef` sau khi đã có `AssetModel` mang giá trị dưới key đó trong `specs`, giá trị cũ trở thành orphaned/stale — chưa có cơ chế migration/cleanup. Ghi nhận là known gap để xử lý khi có UI quản lý attribute-def thật, không cần giải quyết ngay bây giờ.
|
||||
|
||||
**Ràng buộc dữ liệu:**
|
||||
- `AssetModel.categoryId` bắt buộc, phải trỏ tới 1 `AssetCategory` có `valueGroup = high_value`.
|
||||
- `AssetItem.modelId` bắt buộc, phải trỏ tới 1 `AssetModel` có thật (thay cho field `model` string tự do trước đây — đã đổi, không cần chiến lược backfill vì DB chưa có `AssetItem` thật). `AssetItem` không còn giữ `categoryId` trực tiếp — category được suy ra qua `AssetItem.modelId → AssetModel.categoryId` (tránh 2 nguồn sự thật có thể lệch nhau).
|
||||
- `ConsumableItem.categoryId` bắt buộc, phải trỏ tới 1 `AssetCategory` có `valueGroup = low_value` — trỏ thẳng, không qua `AssetModel`.
|
||||
- `Request` (state machine đầy đủ) tiếp tục chỉ áp dụng cho `type: asset|license` như hiện tại — **không** mở rộng `RequestType` thêm `consumable`.
|
||||
- `AssetCategoryAttributeDef.categoryId` bắt buộc, phải trỏ tới 1 `AssetCategory` có thật. Unique `(categoryId, key)`.
|
||||
- `AssetModel.specs` (JSONB) chỉ được chứa các key đã có `AssetCategoryAttributeDef` tương ứng với category của model đó — validate ở tầng Zod (build động lúc runtime), không phải DB CHECK constraint (nhất quán với nguyên tắc "không raw SQL cho app logic" của codebase).
|
||||
|
||||
**Luồng riêng cho consumable ("xin + nhận" đơn giản hoá):** stakeholder đã xác nhận muốn 1 luồng gọn hơn state machine đầy đủ của `Request`, nhưng **chưa chốt chi tiết state/approval/xử lý hết hàng** — xem danh sách câu hỏi mở trong spec BA (2026-08-02, lưu ở lịch sử trao đổi/PR liên quan). Không build luồng này (entity `ConsumableRequest` hoặc tương đương) tới khi có câu trả lời — chỉ mới build phần catalog (`AssetCategory`, `ConsumableItem`).
|
||||
|
||||
## Vendor (chốt 2026-08-02)
|
||||
|
||||
Entity mới, độc lập với `AssetModel`.
|
||||
|
||||
- Field: `id`, `name`, `contactEmail`, `contactPhone`, `address`, `taxCode` (nullable — mã số thuế VN), `notes` (nullable).
|
||||
- **Phân biệt quan trọng:** `Vendor` (nơi mua hàng — vd nhà phân phối/reseller VN) là khái niệm **khác** `AssetModel.manufacturer` (hãng sản xuất/OEM, vd "Dell") — không phải lúc nào cũng cùng 1 công ty (vd mua laptop Dell qua reseller "FPT Trading", không mua thẳng từ Dell). Quyết định: giữ nguyên `AssetModel.manufacturer` là string tự do như hiện tại (không đổi gì); `Vendor` chỉ dùng cho quan hệ mua sắm (`PurchaseOrder`, `License`).
|
||||
- **Retrofit `License.vendor`** (hiện là string tự do) → **`License.vendorId`** (FK → `Vendor`), theo đúng tiền lệ đã có trong codebase (`Request.employeeId` string→FK khi `Employee` ra đời, `AssetItem.model` string→FK khi `AssetModel` ra đời — xem "Trạng thái hiện tại" ở `../CLAUDE.md`). Chưa có `License` thật nào trong DB production nên đây là đổi kiểu cột thuần tuý, **không cần chiến lược backfill** — giống các retrofit trước đó.
|
||||
|
||||
**Ràng buộc dữ liệu:**
|
||||
- `License.vendorId` bắt buộc, phải trỏ tới 1 `Vendor` có thật (thay cho field `vendor` string tự do trước đây).
|
||||
|
||||
## PurchaseOrder (gộp Receipt) (chốt 2026-08-02)
|
||||
|
||||
**Quyết định: gộp `Receipt` vào `PurchaseOrder`, không giữ là entity riêng nữa** — nhận hàng chỉ là 1 phần trạng thái của chính PO đó (`receivedAt`/`receivedBy`), không cần 1 bảng riêng để đối chiếu. Row `Receipt` trong bảng Data model ở trên đã được xoá, gộp vào row `PurchaseOrder`.
|
||||
|
||||
- Field: `id`, `vendorId` (FK → `Vendor`), `orderDate`, `expectedDate`, `receivedAt` (nullable DateTime — null nghĩa là chưa nhận), `receivedBy` (nullable FK → `Employee`, set cùng lúc với `receivedAt`).
|
||||
- Cố tình **không** có field giá/chi phí — stakeholder nói rõ `PurchaseOrder` không cần track tiền, chỉ cần trả lời "asset/license nào thuộc PO nào".
|
||||
- Cố tình **không** có field `PurchaseOrder.status` riêng — trạng thái suy ra từ trạng thái của các `Request` liên kết (`Request` đã có state machine `Ordered → Received → Delivered` riêng), tránh 2 nguồn sự thật.
|
||||
- **Nhận hàng là all-or-nothing theo từng PO** ("nhận 1 lần đủ cho cả PO") — không hỗ trợ nhận nhiều đợt/từng phần. Đây là đánh đổi đơn giản hoá **có chủ ý**, stakeholder đã biết rõ và chấp nhận có thể cần đổi lại sau (paraphrase: "giờ làm đơn giản vậy trước, sau này cần đổi thì tính tiếp") — ghi lại rõ đây là tradeoff/assumption đã note, không phải bỏ sót khi viết spec.
|
||||
- **Traceability** (đúng yêu cầu cốt lõi của stakeholder: "chỉ cần biết asset nằm trong PO nào"): thêm `purchaseOrderId` (nullable FK → `PurchaseOrder`) ở **CẢ BA** chỗ — `AssetItem`, `License`, và `Request`. Ba field này phục vụ mục đích khác nhau, không dư thừa:
|
||||
- `Request.purchaseOrderId` — chiều cầu (demand-side): request nào đã duyệt được gộp vào PO này (đây vốn là mục đích ban đầu đã note trong doc cho `PurchaseOrder`: "gộp nhiều Request đã duyệt").
|
||||
- `AssetItem.purchaseOrderId` và `License.purchaseOrderId` — chiều cung (supply-side): record tồn kho/thực tế nào tới qua PO này. Set lúc intake/tạo mới. Nullable vì không phải `AssetItem`/`License` nào cũng đi qua 1 PO chính thức (vd tồn kho lịch sử nhập trước khi có hệ thống).
|
||||
|
||||
**Ràng buộc dữ liệu:**
|
||||
- `PurchaseOrder.vendorId` bắt buộc, trỏ tới 1 `Vendor` có thật.
|
||||
- `PurchaseOrder.receivedBy` chỉ set cùng lúc với `receivedAt` (cả hai cùng null hoặc cùng có giá trị).
|
||||
- `AssetItem.purchaseOrderId`, `License.purchaseOrderId`, `Request.purchaseOrderId` đều nullable, trỏ tới `PurchaseOrder` nếu có.
|
||||
- `PurchaseOrder` không có field `status` — suy ra qua `Request` liên kết, không lưu trùng.
|
||||
|
||||
## Assignment (chốt 2026-08-02)
|
||||
|
||||
- Field: `id`, `employeeId` (FK → `Employee`), `assetItemId` (FK → `AssetItem`, nullable), `licenseId` (FK → `License`, nullable), `assignedAt`, `returnedAt` (nullable — null nghĩa đang giữ), `signedAt` (nullable DateTime — null nghĩa biên bản chưa ký. Cố tình chỉ là 1 timestamp đánh dấu đã ký/chưa, **KHÔNG** phải upload file — stakeholder đã quyết định rõ không cần lưu file scan lúc này, "biên bản ký nhận" chỉ cần đánh dấu đã ký + ngày ký).
|
||||
- Đúng 1 trong 2 (`assetItemId`/`licenseId`) phải được set — không cả hai, không cả hai đều rỗng (XOR) — validate ở tầng Zod qua `.refine()`, theo đúng cách codebase đã xử lý validate cross-field (không phải DB CHECK constraint, đúng nguyên tắc "không raw SQL cho app logic" — Prisma schema cũng không diễn tả được constraint XOR này natively).
|
||||
- Không phải entity mới, nhưng có các business rule tầng application cần note lại rõ ở đây (đủ quan trọng để phát sinh bug nếu bỏ sót, nhưng là rule app phải tự enforce, không phải bản thân là 1 entity):
|
||||
- 1 `AssetItem` chỉ có tối đa 1 `Assignment` **active** (`returnedAt IS NULL`) tại 1 thời điểm — không thể bị giữ bởi 2 người cùng lúc. Enforce trước khi tạo `Assignment` mới.
|
||||
- Assign 1 `License` phải **tăng** `License.seatsUsed`, và phải **bị từ chối** nếu `seatsUsed >= seatsTotal` (không overbook). Trả lại (set `returnedAt`) **giảm** `seatsUsed`. Toàn bộ phải chạy trong 1 transaction — đây chính là rủi ro seat-count race condition mà `code-reviewer` đã note trước đó cho `License` (xem "Trạng thái hiện tại" ở `../CLAUDE.md`, mục `License`) — `Assignment` là chỗ thực sự implement/exercise seat counting lần đầu tiên.
|
||||
- Chuyển tài sản giữa nhân viên (case đặc biệt đã note ở trên: "Tài sản luân chuyển giữa phòng ban / chi nhánh") = đóng `Assignment` cũ (`returnedAt = now`) + tạo `Assignment` mới, cả hai trong 1 transaction. Không cần entity "Transfer" riêng.
|
||||
- `AssetItem.status` phải đồng bộ theo trạng thái `Assignment`: tạo `Assignment` → status `assigned`; trả lại → status `in_stock`, trừ nhánh RMA/sửa chữa đã note ở "Case đặc biệt cần xử lý" (đó là case riêng, không đi qua path trả-lại-thông-thường này).
|
||||
|
||||
**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).
|
||||
- `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)
|
||||
|
||||
Mục tiêu xa hơn: biết chính xác asset nằm ở ô/slot nào trên tủ rack, và hướng dẫn người lấy đúng ô bằng đèn (pick-to-light).
|
||||
@@ -93,7 +167,8 @@ QR gán asset vào slot (bước 1, thủ công hàng ngày) **không phải IoT
|
||||
## Việc còn dở
|
||||
|
||||
- Thiết kế ERD chi tiết + wireframe màn hình
|
||||
- Entity còn lại: `License`, `PurchaseOrder`, `Receipt`, `Assignment` (`Employee` và `AssetItem` đã xong 2026-07-31, xem `../CLAUDE.md`)
|
||||
- **`Vendor`, `PurchaseOrder` (gộp `Receipt`), `Assignment`, `AssetCategoryAttributeDef` (2026-08-02): đã có spec đầy đủ (BA), sẵn sàng cho `pm` chia task** — xem các mục "Vendor", "PurchaseOrder (gộp Receipt)", "Assignment" và "Cập nhật 2026-08-02 (3)" trong "Phân loại tài sản" ở trên. Bao gồm cả retrofit `License.vendor` (string) → `License.vendorId` (FK → `Vendor`, không cần backfill vì chưa có `License` thật trong DB) và thêm `purchaseOrderId` (nullable FK) vào `AssetItem`/`License`/`Request`. Thứ tự phụ thuộc gợi ý cho `pm`: `Vendor` → retrofit `License.vendorId` → `PurchaseOrder` (+ `purchaseOrderId` trên 3 entity) → `Assignment`; `AssetCategoryAttributeDef` độc lập, có thể làm song song.
|
||||
- Consumable (2026-08-02): phần phân loại (`AssetCategory`, `ConsumableItem`) đã có spec đầy đủ (BA), sẵn sàng cho `pm` chia task. Luồng "xin + nhận" (`ConsumableRequest` hoặc tương đương) **BỊ CHẶN** — cần stakeholder trả lời các câu hỏi mở: (1) có cần approval không, ai duyệt; (2) ai được tạo request xin (tự xin cho mình hay xin hộ được); (3) có giới hạn số lượng tối đa/lần xin không; (4) hết hàng thì chặn hẳn / cho "nợ" chờ nhập thêm / fulfill một phần; (5) trừ tồn kho lúc tạo request hay lúc fulfill; (6) restock tồn kho qua luồng nào, ai được làm; (7) có cần ghi nhận người phát hàng/ký nhận không. Không tự đoán — xem chi tiết đầy đủ trong spec BA.
|
||||
- Migrate 2 request đang có (`Firefly_License_Request.docx`, `Eric Pelatan.docx`, ở workspace `ubisoft/` ngoài repo này) vào hệ thống mới sau khi có DB
|
||||
- (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.
|
||||
@@ -103,4 +178,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.
|
||||
|
||||
---
|
||||
Cập nhật lần cuối: 2026-07-30
|
||||
Cập nhật lần cuối: 2026-08-02 (spec `Vendor`, `PurchaseOrder` gộp `Receipt`, `Assignment`, `AssetCategoryAttributeDef` — sẵn sàng cho `pm` chia task)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LicenseType" AS ENUM ('perpetual', 'subscription');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "licenses" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"vendor" TEXT NOT NULL,
|
||||
"seats_total" INTEGER NOT NULL,
|
||||
"seats_used" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiry_date" DATE,
|
||||
"license_type" "LicenseType" NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "licenses_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -92,6 +92,36 @@ model AssetItem {
|
||||
@@map("asset_items")
|
||||
}
|
||||
|
||||
// Fixed, closed set of 2 values tied to the domain (see docs/
|
||||
// asset-license-management.md's "Data model (sơ bộ)" table) — native
|
||||
// Postgres enum, same rationale as RequestType/AssetItemStatus above.
|
||||
enum LicenseType {
|
||||
perpetual
|
||||
subscription
|
||||
}
|
||||
|
||||
// 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,
|
||||
// loại). `vendor` is deliberately a plain String for now, not a separate
|
||||
// Vendor entity — no Vendor entity exists yet, same "plain string for now"
|
||||
// precedent as Employee.department. `seatsUsed` is a stored counter
|
||||
// (defaults to 0 at creation), not a computed value — it will only ever be
|
||||
// set to 0 here since Assignment (the thing that would consume seats)
|
||||
// doesn't exist yet. `expiryDate` is nullable: perpetual licenses never
|
||||
// expire, only subscription licenses usually have one.
|
||||
model License {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
vendor String
|
||||
seatsTotal Int @map("seats_total")
|
||||
seatsUsed Int @default(0) @map("seats_used")
|
||||
expiryDate DateTime? @map("expiry_date") @db.Date
|
||||
licenseType LicenseType @map("license_type")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("licenses")
|
||||
}
|
||||
|
||||
// Minimal internal auth accounts — NOT company SSO (deliberately ruled out,
|
||||
// see CLAUDE.md / auth decision notes). A handful of trusted IT staff
|
||||
// accounts, created by hand via scripts/create-user.ts, no self-registration.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { createLicense, listLicenses, validateCreateLicenseInput } from "@/lib/licenses";
|
||||
|
||||
// Mirrors src/app/api/assets/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, vendor, seatsTotal, expiryDate, licenseType } = body as Record<string, unknown>;
|
||||
const validation = validateCreateLicenseInput({
|
||||
name,
|
||||
vendor,
|
||||
seatsTotal,
|
||||
expiryDate,
|
||||
licenseType,
|
||||
});
|
||||
|
||||
if (!validation.ok) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const created = await createLicense(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 licenses = await listLicenses();
|
||||
return NextResponse.json(licenses, { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { z } from "zod";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
export type LicenseType = "perpetual" | "subscription";
|
||||
|
||||
export interface LicenseRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
vendor: string;
|
||||
seats_total: number;
|
||||
seats_used: number;
|
||||
expiry_date: string | null;
|
||||
license_type: LicenseType;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateLicenseInput {
|
||||
name: string;
|
||||
vendor: string;
|
||||
seatsTotal: number;
|
||||
expiryDate?: Date | null;
|
||||
licenseType: LicenseType;
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
| { ok: true; value: CreateLicenseInput }
|
||||
| { ok: false; error: string };
|
||||
|
||||
// Mirrors src/lib/assets.ts's nonEmptyString helper.
|
||||
function nonEmptyString(message: string) {
|
||||
return z.string({ required_error: message, invalid_type_error: message }).refine(
|
||||
(value) => value.trim() !== "",
|
||||
{ message }
|
||||
);
|
||||
}
|
||||
|
||||
// Mirrors src/lib/assets.ts's dateString helper — accepts any string
|
||||
// Date.parse() can handle (e.g. "2026-07-31") and transforms it into a Date
|
||||
// for Prisma.
|
||||
function dateString(message: string) {
|
||||
return z
|
||||
.string({ required_error: message, invalid_type_error: message })
|
||||
.refine((value) => !Number.isNaN(Date.parse(value)), { message })
|
||||
.transform((value) => new Date(value));
|
||||
}
|
||||
|
||||
const createLicenseSchema = z.object({
|
||||
name: nonEmptyString("name is required and must be a non-empty string"),
|
||||
vendor: nonEmptyString("vendor is required and must be a non-empty string"),
|
||||
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" })
|
||||
.int({ message: "seatsTotal is required and must be a positive integer" })
|
||||
.positive({ message: "seatsTotal is required and must be a positive integer" }),
|
||||
expiryDate: dateString("expiryDate must be a valid date string").optional().nullable(),
|
||||
licenseType: z.enum(["perpetual", "subscription"], {
|
||||
errorMap: () => ({
|
||||
message: "licenseType is required and must be either 'perpetual' or 'subscription'",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates raw (untyped) license-creation input. name/vendor must be
|
||||
* non-empty strings, seatsTotal must be a positive integer, expiryDate is
|
||||
* optional/nullable but must be a valid date string if present, licenseType
|
||||
* must be one of the two enum values. seatsUsed is not accepted here — new
|
||||
* licenses always start at 0 (same pattern as AssetItem always starting
|
||||
* "in_stock"), seats are only ever consumed via other operations (once
|
||||
* Assignment exists), not at creation.
|
||||
*/
|
||||
export function validateCreateLicenseInput(input: {
|
||||
name?: unknown;
|
||||
vendor?: unknown;
|
||||
seatsTotal?: unknown;
|
||||
expiryDate?: unknown;
|
||||
licenseType?: unknown;
|
||||
}): ValidationResult {
|
||||
const result = createLicenseSchema.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;
|
||||
vendor: string;
|
||||
seatsTotal: number;
|
||||
seatsUsed: number;
|
||||
expiryDate: Date | null;
|
||||
licenseType: LicenseType;
|
||||
createdAt: Date;
|
||||
}): LicenseRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
vendor: row.vendor,
|
||||
seats_total: row.seatsTotal,
|
||||
seats_used: row.seatsUsed,
|
||||
expiry_date: row.expiryDate ? row.expiryDate.toISOString() : null,
|
||||
license_type: row.licenseType,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a License. No unique constraint exists on `name` (unlike
|
||||
* AssetItem.serialNumber), so unlike createAssetItem() there's no known
|
||||
* Prisma error code to catch/translate here.
|
||||
*/
|
||||
export async function createLicense(input: CreateLicenseInput): Promise<LicenseRecord> {
|
||||
const created = await prisma.license.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
vendor: input.vendor,
|
||||
seatsTotal: input.seatsTotal,
|
||||
expiryDate: input.expiryDate ?? null,
|
||||
licenseType: input.licenseType,
|
||||
},
|
||||
});
|
||||
return toRecord(created);
|
||||
}
|
||||
|
||||
export async function listLicenses(): Promise<LicenseRecord[]> {
|
||||
const rows = await prisma.license.findMany({
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
});
|
||||
return rows.map(toRecord);
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Mock } from "vitest";
|
||||
import type { Session } from "next-auth";
|
||||
import {
|
||||
createLicense,
|
||||
listLicenses,
|
||||
validateCreateLicenseInput,
|
||||
} from "@/lib/licenses";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Note: this file has two layers of coverage, mirroring tests/assets.test.ts:
|
||||
// - lib-level tests below exercise src/lib/licenses.ts directly (validation,
|
||||
// seatsUsed always 0, list ordering).
|
||||
// - route-level tests (further down) exercise src/app/api/licenses/route.ts
|
||||
// over HTTP, including the auth() gate — mirrors tests/assets.test.ts's
|
||||
// @/auth mocking pattern.
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
|
||||
import { GET, POST } from "@/app/api/licenses/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/licenses", {
|
||||
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.license.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("validateCreateLicenseInput", () => {
|
||||
it("accepts valid input with an expiryDate", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
seatsTotal: 10,
|
||||
expiryDate: "2027-01-15",
|
||||
licenseType: "subscription",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toMatchObject({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
expect(result.value.expiryDate).toBeInstanceOf(Date);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts valid input with no expiryDate (perpetual license)", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Windows Server 2022",
|
||||
vendor: "Microsoft",
|
||||
seatsTotal: 5,
|
||||
licenseType: "perpetual",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.expiryDate).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an empty name", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "",
|
||||
vendor: "Adobe",
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/name/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an empty vendor", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "",
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/vendor/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a zero seatsTotal", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
seatsTotal: 0,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/seatsTotal/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a negative seatsTotal", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
seatsTotal: -5,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/seatsTotal/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a non-integer seatsTotal", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
seatsTotal: 3.5,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/seatsTotal/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an invalid expiryDate", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
seatsTotal: 10,
|
||||
expiryDate: "not-a-date",
|
||||
licenseType: "subscription",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/expiryDate/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an invalid licenseType", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
seatsTotal: 10,
|
||||
licenseType: "trial",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/licenseType/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a missing licenseType", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
seatsTotal: 10,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/licenseType/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLicense", () => {
|
||||
it("creates a license with seatsUsed always 0, regardless of client input", async () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
seatsTotal: 10,
|
||||
// @ts-expect-error -- seatsUsed is intentionally not part of
|
||||
// CreateLicenseInput; this simulates a client trying to sneak it in.
|
||||
seatsUsed: 999,
|
||||
expiryDate: "2027-01-15",
|
||||
licenseType: "subscription",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const record = await createLicense(result.value);
|
||||
|
||||
expect(record.seats_used).toBe(0);
|
||||
expect(record).toMatchObject({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
seats_total: 10,
|
||||
license_type: "subscription",
|
||||
});
|
||||
expect(typeof record.id).toBe("string");
|
||||
expect(record.id.length).toBeGreaterThan(0);
|
||||
expect(Number.isNaN(Date.parse(record.expiry_date as string))).toBe(false);
|
||||
expect(Number.isNaN(Date.parse(record.created_at))).toBe(false);
|
||||
});
|
||||
|
||||
it("creates a perpetual license with no expiryDate", async () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Windows Server 2022",
|
||||
vendor: "Microsoft",
|
||||
seatsTotal: 5,
|
||||
licenseType: "perpetual",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const record = await createLicense(result.value);
|
||||
|
||||
expect(record.expiry_date).toBeNull();
|
||||
expect(record.seats_used).toBe(0);
|
||||
expect(record.license_type).toBe("perpetual");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listLicenses", () => {
|
||||
it("lists created licenses, newest first", async () => {
|
||||
const first = validateCreateLicenseInput({
|
||||
name: "Windows Server 2022",
|
||||
vendor: "Microsoft",
|
||||
seatsTotal: 5,
|
||||
licenseType: "perpetual",
|
||||
});
|
||||
expect(first.ok).toBe(true);
|
||||
if (first.ok) await createLicense(first.value);
|
||||
|
||||
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
const second = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
expect(second.ok).toBe(true);
|
||||
if (second.ok) await createLicense(second.value);
|
||||
|
||||
const records = await listLicenses();
|
||||
|
||||
expect(records).toHaveLength(2);
|
||||
// newest first
|
||||
expect(records[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor: "Adobe" });
|
||||
expect(records[1]).toMatchObject({ name: "Windows Server 2022", vendor: "Microsoft" });
|
||||
});
|
||||
|
||||
it("returns an empty list when there are no licenses", async () => {
|
||||
const records = await listLicenses();
|
||||
expect(records).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/licenses", () => {
|
||||
it("creates a license with seatsUsed always 0 on valid input", async () => {
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
seatsTotal: 10,
|
||||
expiryDate: "2027-01-15",
|
||||
licenseType: "subscription",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body).toMatchObject({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
seats_total: 10,
|
||||
seats_used: 0,
|
||||
license_type: "subscription",
|
||||
});
|
||||
expect(typeof body.id).toBe("string");
|
||||
expect(body.id.length).toBeGreaterThan(0);
|
||||
expect(Number.isNaN(Date.parse(body.expiry_date))).toBe(false);
|
||||
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
|
||||
});
|
||||
|
||||
it("creates a perpetual license with no expiryDate", async () => {
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "Windows Server 2022",
|
||||
vendor: "Microsoft",
|
||||
seatsTotal: 5,
|
||||
licenseType: "perpetual",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.expiry_date).toBeNull();
|
||||
expect(body.seats_used).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an empty name", async () => {
|
||||
const res = await POST(
|
||||
postRequest({ name: "", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/name/i);
|
||||
});
|
||||
|
||||
it("rejects an empty vendor", async () => {
|
||||
const res = await POST(
|
||||
postRequest({ name: "Adobe Creative Cloud", vendor: "", seatsTotal: 10, licenseType: "subscription" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/vendor/i);
|
||||
});
|
||||
|
||||
it("rejects a zero seatsTotal", async () => {
|
||||
const res = await POST(
|
||||
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 0, licenseType: "subscription" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/seatsTotal/i);
|
||||
});
|
||||
|
||||
it("rejects an invalid expiryDate", async () => {
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
seatsTotal: 10,
|
||||
expiryDate: "not-a-date",
|
||||
licenseType: "subscription",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/expiryDate/i);
|
||||
});
|
||||
|
||||
it("rejects an invalid licenseType", async () => {
|
||||
const res = await POST(
|
||||
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "trial" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/licenseType/i);
|
||||
});
|
||||
|
||||
it("rejects an invalid JSON body", async () => {
|
||||
const req = new Request("http://localhost/api/licenses", {
|
||||
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/assets) — 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/licenses", () => {
|
||||
it("lists created licenses, newest first", async () => {
|
||||
await POST(
|
||||
postRequest({ name: "Windows Server 2022", vendor: "Microsoft", seatsTotal: 5, licenseType: "perpetual" })
|
||||
);
|
||||
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await POST(
|
||||
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
|
||||
);
|
||||
|
||||
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: "Adobe Creative Cloud", vendor: "Adobe" });
|
||||
expect(body[1]).toMatchObject({ name: "Windows Server 2022", vendor: "Microsoft" });
|
||||
});
|
||||
|
||||
it("returns an empty list when there are no licenses", async () => {
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth gating on /api/licenses", () => {
|
||||
// Mirrors tests/assets.test.ts's "auth gating" block — only checks
|
||||
// session presence, per the flat permission model (no role/permission
|
||||
// logic).
|
||||
it("POST /api/licenses returns 401 without a session", async () => {
|
||||
mockedAuth.mockResolvedValue(null);
|
||||
|
||||
const res = await POST(
|
||||
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
|
||||
);
|
||||
|
||||
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.license.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("GET /api/licenses 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