8 Commits
Author SHA1 Message Date
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 795 additions and 16 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 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. - **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. - **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. - 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. - 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. - 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). 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. # .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. # Do NOT reuse this placeholder — must be a real per-environment secret.
AUTH_SECRET=change-me-generate-a-real-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. - `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. - `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 ## 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. 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ể).
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. 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ụ.
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. 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.
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. 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 ### 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. - 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". - 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. - 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 ## 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). - **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ư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). - **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). - **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`. - `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. - `/` (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). - **`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. - **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. - **`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ở ## 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). - `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"). - 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`). - 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. - 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. - Rà lại advisory riêng của `next` (prod dependency, có vài high/moderate — vd SSRF qua WebSocket upgrade, middleware bypass) khi có dịp, tách biệt khỏi đợt retrofit Prisma/Zod này.
- Test coverage gaps (chưa block gì, chỉ là follow-up): chưa test malformed JSON body, chưa test JSON body không phải object (array/string). - Test coverage gaps (chưa block gì, chỉ là follow-up): chưa test malformed JSON body, chưa test JSON body không phải object (array/string).
- `Button` (shadcn) không `forwardRef`, React đang pin 18 — dormant risk, xem note ở "Trạng thái hiện tại". - `Button` (shadcn) không `forwardRef`, React đang pin 18 — dormant risk, xem note ở "Trạng thái hiện tại".
- Các entity còn lại: `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 - Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled
- UI (hiện chỉ có placeholder home page) - UI (hiện chỉ có placeholder home page)
- Auth/phân quyền (ai được approve request, ai được xác nhận nhận hàng/bàn giao) - Auth/phân quyền (ai được approve request, ai được xác nhận nhận hàng/bàn giao)
--- ---
Cập nhật lần cuối: 2026-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: 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: db:
image: postgres:16-alpine image: postgres:16-alpine
container_name: asset-license-mgmt-db container_name: asset-license-mgmt-db
+2 -2
View File
@@ -93,7 +93,7 @@ QR gán asset vào slot (bước 1, thủ công hàng ngày) **không phải IoT
## Việc còn dở ## Việc còn dở
- Thiết kế ERD chi tiết + wireframe màn hình - Thiết kế ERD chi tiết + wireframe màn hình
- Entity còn lại: `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`)
- Migrate 2 request đang có (`Firefly_License_Request.docx`, `Eric Pelatan.docx`, ở workspace `ubisoft/` ngoài repo này) vào hệ thống mới sau khi có DB - Migrate 2 request đang có (`Firefly_License_Request.docx`, `Eric Pelatan.docx`, ở workspace `ubisoft/` ngoài repo này) vào hệ thống mới sau khi có DB
- (Xa hơn, chưa ưu tiên) Rack/Slot + IoT pick-to-light, cron email cảnh báo license hết hạn, Power BI report views - (Xa hơn, chưa ưu tiên) Rack/Slot + IoT pick-to-light, cron email cảnh báo license hết hạn, Power BI report views
- Setup Gitea tự host ở nhà (2026-07-31) — dự định làm remote chính sau này, giữ GitHub làm backup off-site (đây là "sản phẩm dùng cả đời" của user, không nên chỉ có 1 bản duy nhất). Tạm thời vẫn push GitHub như hiện tại, chưa làm gấp. - Setup Gitea tự host ở nhà (2026-07-31) — dự định làm remote chính sau này, giữ GitHub làm backup off-site (đây là "sản phẩm dùng cả đời" của user, không nên chỉ có 1 bản duy nhất). Tạm thời vẫn push GitHub như hiện tại, chưa làm gấp.
@@ -103,4 +103,4 @@ QR gán asset vào slot (bước 1, thủ công hàng ngày) **không phải IoT
- Migrate entity `Request` từ SQLite sang Postgres (2026-07-30) và setup ESLint đều đã **CONFIRMED xong** qua lượt verify độc lập (test-runner riêng, không phải agent vừa code tự chấm) — 6 test pass trên Postgres thật, lint sạch, type-check sạch. Chi tiết trạng thái nằm ở `../CLAUDE.md`, không duplicate ở đây. - Migrate entity `Request` từ SQLite sang Postgres (2026-07-30) và setup ESLint đều đã **CONFIRMED xong** qua lượt verify độc lập (test-runner riêng, không phải agent vừa code tự chấm) — 6 test pass trên Postgres thật, lint sạch, type-check sạch. Chi tiết trạng thái nằm ở `../CLAUDE.md`, không duplicate ở đây.
--- ---
Cập nhật lần cuối: 2026-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") @@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, // Minimal internal auth accounts — NOT company SSO (deliberately ruled out,
// see CLAUDE.md / auth decision notes). A handful of trusted IT staff // see CLAUDE.md / auth decision notes). A handful of trusted IT staff
// accounts, created by hand via scripts/create-user.ts, no self-registration. // accounts, created by hand via scripts/create-user.ts, no self-registration.
+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" });
});
});