Author SHA1 Message Date
toanhoangandClaude Sonnet 5 e319f88a75 docs: add BA spec for high/low-value classification + per-category custom fields
Records two pending business decisions in docs/asset-license-management.md:
AssetCategory/AssetModel/ConsumableItem classification (already decided
2026-08-02, carried forward from local WIP), and a new spec for Snipe-IT-style
per-category custom fields (AssetFieldDefinition/AssetFieldValue, EAV-based).
Custom fields spec is blocked on stakeholder answers to open questions
(required flag, editing definitions after assets exist, whether it applies to
ConsumableItem) before pm can chunk implementation tasks.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 12:40:30 -04:00
toanhoang 3a18414d03 docs: add ba<->pm feedback loop + commit-mapping to workflow
pm now asks ba back when a spec gap could change business behavior,
instead of guessing; task lists must also map cleanly onto the
existing per-commit convention (model+migration/lib/API/tests).
2026-08-02 11:24:35 -04:00
toanhoangandClaude Sonnet 5 296a8c5d35 docs: update CLAUDE.md + docs for License entity
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 08:55:51 -04:00
toanhoang 5af1465a91 test: add License API tests 2026-08-02 08:55:08 -04:00
toanhoang 7b47385509 feat: add License API routes 2026-08-02 08:55:03 -04:00
toanhoang e232658b3d feat: add License validation + CRUD (src/lib/licenses.ts) 2026-08-02 08:54:57 -04:00
toanhoang 96088421ba feat: add License Prisma model + migration 2026-08-02 08:54:53 -04:00
toanhoangandClaude Sonnet 5 9e08c3f994 feat: add ba + pm agents to multi-agent dev workflow
Stakeholder should only need to state a business goal/priority, not
pick between implementation options or break work into tasks
themselves. `ba` turns a business ask into a concrete spec grounded in
docs/asset-license-management.md, `pm` breaks that spec into an
ordered, dependency-aware sequence of feature-implementer-sized tasks.
Neither writes code or resolves business-rule ambiguity — that stays
with the stakeholder.

Also fixes code-reviewer.md/feature-implementer.md pointing at the old
../processes/asset-license-management.md path — the doc moved into
docs/ inside this repo a while ago.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 08:36:21 -04:00
toanhoangandClaude Sonnet 5 87e2cca019 feat: add Dockerfile + app service for production deploy
Adds a production Docker image (node:20-alpine) and an `app` service in
docker-compose.yml so the Next.js app can run as a long-lived container
instead of just build/test in ephemeral containers. Requires `apk add
openssl` before `npm ci`, otherwise Prisma silently picks the wrong query
engine binary on Alpine and only fails at runtime. Also wires
AUTH_TRUST_HOST=true, required by Auth.js when running behind the NPM
reverse proxy.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 08:25:25 -04:00
15 changed files with 845 additions and 19 deletions
+16
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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).
+18
View File
@@ -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."
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
.git
.env
.env*.local
npm-debug.log*
*.tsbuildinfo
+5
View File
@@ -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
+19 -12
View File
@@ -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,23 @@ 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")
- 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`.
- 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)
+19
View File
@@ -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"]
+13
View File
@@ -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
+52 -5
View File
@@ -24,13 +24,18 @@ Theo dõi toàn bộ vòng đời tài sản/license: **Request → Approval →
|---|---|
| `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) |
| `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, 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 |
| `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` | 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 |
| `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) |
| `AssetCategory` | id, tên (vd "Laptop", "Cáp HDMI"), nhóm giá trị cố định (`high_value` \| `low_value`) — danh mục category cụ thể do IT admin tự định nghĩa, không hardcode. Xem "Phân loại tài sản" bên dưới |
| `AssetModel` | id, category (FK → `AssetCategory`, bắt buộc, phải thuộc nhóm `high_value`), `manufacturer` (vd "Dell"), `name` (vd "Latitude 5420") — tách riêng 2 field, không gộp chung 1 string, để filter/report theo hãng. Nằm giữa `AssetCategory` và `AssetItem` (1 model có nhiều `AssetItem` serial khác nhau). Xem "Phân loại tài sản" bên dưới |
| `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
@@ -40,6 +45,46 @@ 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ũ.
**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`.
**Luồng riêng cho consumable ("xin + nhận" đơn giản hoá):** stakeholder đã xác nhận muốn 1 luồng gọn hơn state machine đầy đủ của `Request`, nhưng **chưa chốt chi tiết state/approval/xử lý hết hàng** — xem danh sách câu hỏi mở trong spec BA (2026-08-02, lưu ở lịch sử trao đổi/PR liên quan). Không build luồng này (entity `ConsumableRequest` hoặc tương đương) tới khi có câu trả lời — chỉ mới build phần catalog (`AssetCategory`, `ConsumableItem`).
## Custom fields theo Category (Additional Options) (spec BA 2026-08-02)
**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 đó.
**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.
**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.
- `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).
**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?
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).
## 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 +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ở
- 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`)
- 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.
- 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
- (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 +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.
---
Cập nhật lần cuối: 2026-07-30
Cập nhật lần cuối: 2026-08-02
@@ -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")
);
+30
View File
@@ -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.
+50
View File
@@ -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 });
}
+131
View File
@@ -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);
}
+467
View File
@@ -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" });
});
});