Root-cause fix for cross-file DB pollution in the vitest suite, which surfaced as intermittent FK-violation failures once the suite hit 12 test files. tests/setup.ts previously mutated the global process.env.DATABASE_URL and relied on src/lib/prisma.ts's singleton picking it up, which is racy across files sharing a worker process (process.env isn't reset between files even though Vitest resets each file's module registry). fileParallelism: false only masked this. Now each test file's own tests/setup.ts creates its own throwaway Postgres database and vi.mock()s "@/lib/prisma" with a dedicated PrismaClient constructed via the explicit datasourceUrl option, so no mutable state is shared between files and fileParallelism can go back to its default (true). Verified with 4 consecutive full `npm test` runs (327/327 passing each time) and no leftover throwaway databases after each run.
21 KiB
Asset & License Management — Dev Notes
Web app nội bộ quản lý tài sản IT + license cho Ubisoft VN local IT. Luồng chính: Request → Approval → Procurement → Receiving → Delivery → Tracking → Thu hồi/Retire.
Business context, data model, luồng trạng thái, case đặc biệt: xem docs/asset-license-management.md — đó là nguồn brainstorm chính, đừng duplicate nội dung đó ở đây, chỉ link tới.
Stack (đã chốt)
- Next.js (TypeScript) full-stack
- DB: Postgres (đã chốt 2026-07-29, xem lý do trong
docs/asset-license-management.md) - DB access: Prisma (ORM + migrations, đã chốt 2026-07-30 — lý do trong
docs/asset-license-management.md). Không viết raw SQL cho app logic nữa — ngoại lệ duy nhất là hạ tầng test (tests/setup.tsdùng rawpgClientđể tạo/xoá throwaway database, đó là test infra chứ không phải data access layer). - Validation: Zod (đã chốt 2026-07-30, đi cùng quyết định Prisma)
- UI/CSS: Tailwind CSS v4 + shadcn/ui (CLI
@latest, hiện tại v4.16.0) — nâng cấp từ Tailwind v3 +[email protected]ngày 2026-07-30, xem lý do quyết định trongdocs/asset-license-management.md. Không còntailwind.config.ts— theme khai báo thẳng trong CSS qua@theme inline { ... }(src/app/globals.css),postcss.config.jschỉ còn plugin@tailwindcss/postcss(không cầnautoprefixerriêng nữa). shadcn config: stylenew-york-v4, base colorneutral, CSS variables bật,tailwind.config: ""(components.json) — đây là style/preset cổ điển (Radix), không phải 8 preset thương hiệu hoá mới của CLI hiện tại (nova/vega/maia/lyra/mira/luma/sera/rhea, chọn qua-p), vốn ép dùng theme "Nova/Vega..." + tuỳ chọn thư viện primitive khác (--base base= Base UI,--base aria= React Aria) — không dùng các preset đó cho project này. Component dùngradix-ui(package hợp nhất, không phải@radix-ui/react-slotriêng lẻ nữa) vàtw-animate-css(thaytailwindcss-animate).globals.csscòn@import "shadcn/tailwind.css"— 1 file CSS runtime do chính packageshadcn(npm) publish kèm nhiều utility (data-state variants, scroll-fade, shimmer...), là dependency thật (không phải chỉ dev tool) vì cần resolve lúc build. Khi thêm component mới:npx shadcn@latest add <component>(đã test non-interactive OK vớicomponents.jsonnày qua flag-y --overwrite). - Self-hosted (theo nguyên tắc chung của workspace — ưu tiên self-host hơn cloud, xem
../processes/new-office-fitout.md)
Coding conventions
Rút ra từ pattern thật đã dùng cho Request (xem src/lib/requests.ts, src/app/api/requests/route.ts, prisma/schema.prisma) — theo đúng pattern này cho các entity tiếp theo, đừng bịa pattern mới:
- 1 file schema Prisma (
prisma/schema.prisma) cho tất cả model — không tách nhiều file. Field trong model dùng camelCase (employeeId), map sang cột DB snake_case qua@map("employee_id"); tên bảng map qua@@map("requests")(snake_case, số nhiều). - 1 file
src/lib/<entity>.tsmỗi entity, chứa cả 3 thứ trong cùng file: Zod schema + hàm validate trả vềValidationResult({ok:true,value}|{ok:false,error}, không throw), các hàm CRUD gọi Prisma Client, và 1 hàmtoRecord()map từ shape Prisma (camelCase) sang shape public API (snake_case — vdemployee_id,created_at). Ranh giới cố ý: nội bộ TS/Prisma dùng camelCase, API trả ra ngoài dùng snake_case. - API route (
src/app/api/<entity>/route.ts) giữ mỏng: parse JSON trong try/catch (400 nếu invalid JSON), checktypeof body === "object" && body !== null(400 nếu không), gọi hàm validate của lib, gọi hàm CRUD, trảNextResponse.json(...)với status code đúng (400 validation lỗi, 201 tạo mới, 200 list). Không viết business logic trong route handler. - Prisma Client: dùng singleton ở
src/lib/prisma.ts(global-guard chống hot-reload spawn nhiều instance) — importprismatừ đó, không tựnew PrismaClient()ở nơi khác. - Enum có tập giá trị cố định gắn với domain (vd
type: asset|license) → Prismaenum, không phảiString+ check tay. - Màu/theme Tailwind: từ khi lên Tailwind v4 (2026-07-30), theme khai báo thẳng trong
src/app/globals.cssqua@theme inline { --color-*: var(--*); ... }— không còntailwind.config.ts. Biến CSS ở:root/.darkđã là giá trịoklch(...)đầy đủ,@theme inlinechỉ alias thẳng quavar(--x), không bọc thêmhsl(...)/rgb(...)(bài học từ bughsl(oklch(...))hồi còn Tailwind v3 +tailwind.config.ts, đã fix rồi bỏ luôn file đó khi lên v4). - Test: 1 file
tests/<entity>.test.tsmirror theo lib file, chạy trên Postgres thật qua DB tạm per-test-file (xemtests/setup.ts) — không mock DB.beforeEachdọn bằngprisma.<model>.deleteMany(). - Schema mới/đổi schema → tạo migration Prisma thật (
prisma migrate dev --name <mô_tả>), không sửa tay SQL.
Git / commit convention
- Message theo Conventional Commits:
feat:,fix:,docs:,chore:,test:,refactor:+ mô tả ngắn gọn ở thì hiện tại (vdfeat: add Employee Prisma model + migration). - Chia nhỏ commit theo lớp kỹ thuật khi build 1 entity/feature mới — không gộp hết vào 1 commit lớn khó review. Thứ tự chuẩn cho 1 entity mới:
feat: add <Entity> Prisma model + migrationfeat: add <Entity> validation + CRUD (src/lib/<entity>.ts)feat: add <Entity> API routestest: add <Entity> API tests
- Nếu 1 lớp cần sửa lại (bug do lớp sau phát hiện), thêm commit
fix:riêng, không gộp ngược vào commit cũ (đã push thì không amend).
- Vẫn chạy xuyên suốt cả entity trong 1 lượt (feature-implementer → test-runner) rồi mới hỏi/báo cáo — không dừng lại xin review sau từng commit nhỏ. User sẽ tự xem lại qua
git log/diff từng commit sau khi xong. - Lưu ý PowerShell trên máy này: đừng để dấu
"(ngoặc kép) lồng bên trong message nhiều dòng khi truyền quagit commit -m— dễ bị PowerShell tách nhầm argument, khiến git báo lỗi pathspec (đã gặp thật, xem cách né ở lịch sử commit). Cách an toàn: gán message vào biến trước ($msg = @'...'@; git commit -m $msg), tránh dấu"trong nội dung, hoặc dùnggit commit -F <file>nếu bắt buộc phải có".
Môi trường
Project này được dùng từ nhiều máy khác nhau (máy Windows công ty, VM Linux ở nhà qua Proxmox) — luôn kiểm tra host hiện tại trước khi giả định cách chạy npm, đừng áp cứng 1 cách cho mọi nơi.
- Máy Windows công ty (
C:\Users\thoangminh\Desktop\ubisoft\asset-license-mgmt): không có Node.js/npm cài native. Mọinpm install/npm test/tscphải chạy qua Docker (node:20container, xem cú pháp bên dưới). Docker Desktop đã confirm hoạt động được trên máy này (dùng để chạy Xibo containers, xem../xibo/). - VM Linux ở nhà (Proxmox): nếu VM có Node.js cài native (kiểm tra bằng
node -v/which nodetrước), chạynpm install/npm test/tsctrực tiếp, không cần wrap qua Docker — chỉ Postgres mới cần Docker (docker compose up -d db), vì đó là quyết định kiến trúc (Postgres qua container), không phải vì thiếu Node. Nếu VM đó cũng không có Node, dùng lại cách wrap Docker như máy Windows. - DB luôn là Postgres qua
docker-compose.yml(servicedb, networkasset-license-mgmt-net, imagepostgres:16-alpine) — cái này giống nhau ở mọi máy, không phụ thuộc host có Node native hay không. Trước khinpm test, phải start Postgres:docker compose up -d db(từ thư mục project).- Nếu phải wrap qua Docker (máy không có Node native): chạy npm trong container gắn cùng network để resolve được hostname
db:docker run --rm --network asset-license-mgmt-net -v "<project dir>:/app" -w /app -e DATABASE_URL="postgres://asset_license_mgmt:change-me-local-dev@db:5432/asset_license_mgmt" node:20 sh -c "npm install && npm test" - Nếu Node native: set
DATABASE_URL(xem.env/.env.example) rồi chạy thẳngnpm install && npm test— Postgres vẫn phải resolve quadbnếu chạy trong Docker network, hoặclocalhost/port map nếu compose có publish port (hiệndocker-compose.ymlkhông publish port ra host, cần thêm port mapping hoặc chạy npm trong cùng Docker network nếu muốn gọi từ ngoài container). - Test tự tạo/xoá một database Postgres tạm (
test_<uuid>) riêng cho mỗi lần chạy file test (xemtests/setup.ts), không đụng vào database dev chính. DATABASE_URLmẫu nằm ở.env.example— copy thành.env(đã gitignore) nếu cần chỉnh local, riêng cho từng máy.
- Nếu phải wrap qua Docker (máy không có Node native): chạy npm trong container gắn cùng network để resolve được hostname
npm installtự chạyprisma generatequa hookpostinstall(xempackage.json) — không cần gọi tay trừ khi thay đổischema.prismamà không chạynpm installlại.pgvẫn còn trongdevDependencies— chỉ dùng cho hạ tầng test (tests/setup.tstạ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 (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), 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: 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 mục tiêu nghiệp vụ stakeholder đưa ra (vd "thêm tracking seat cho License", "cho phép reject Request"):
ba— chuyển yêu cầu ở mức business thành spec cụ thể (field, rule, edge case, acceptance criteria), bám theodocs/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ýAssignmentkhiLicensebị 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ể).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ụ,pmhỏi ngược lạibađể 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ụ.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.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.- Nếu fail: đưa report của test-runner làm input cho một lượt
feature-implementermới để fix. Lặp lại bước 2–3 tới khi xanh hết. 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/pmluôn chạy trước khi có task cụ thể chofeature-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 chofeature-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/pmkhông tự quyết nghiệp vụ mơ hồ — luôn đẩy ngược lại stakeholder thay vì đoán.pmgặ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ạiba(không tự bịa),batrả lời bằng cách tradocs/asset-license-management.mdhoặ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
Backend CRUD xong cho toàn bộ entity chốt tới nay: Request (chỉ state Draft), User+Auth, Employee, AssetItem, License, Vendor, PurchaseOrder, AssetCategory, AssetCategoryAttributeDef, AssetModel, Assignment. Chi tiết field/migration từng entity xem prisma/schema.prisma + git log — không lặp lại ở đây, chỉ ghi những thứ không tự derive được từ code (quyết định, gotcha, rủi ro còn treo).
- Nền tảng (2026-07-30/31): bootstrap Next.js +
Request(Draft), migrate SQLite→Postgres, ESLint, Tailwind v4 + shadcn@latest, Auth.js v5 (Credentials + bcryptjs, flat permission — không role), route-gating quasrc/middleware.ts(default-deny;src/auth.config.tstách riêng vì Credentials provider dùng bcryptjs không chạy được Edge Runtime; API route tự checkauth()vì matcher loại trừ/api). Deploy lên homelab (2026-08-02) tạiubi.toanhoang.work— gotchaapk add opensslđã ghi ở/opt/homelab/CLAUDE.md, không lặp lại ở đây. Request.employeeIdretrofit string→FK (2026-07-31): thay đổi hành vi API có chủ ý —employee_idgiờ bắt buộc UUID hợp lệ + phải trỏ Employee có thật, trả400sạch thay vì 500 nếu không (P2003).License.vendorretrofit string→vendorIdFK (2026-08-02), cùng lúcPurchaseOrder(gộpReceipt) ra đời vớipurchaseOrderId(nullable FK) ở cảAssetItem/License/Request(3 chiều traceability khác nhau — xemdocs/asset-license-management.md).- Phân cấp tài sản (2026-08-03):
AssetCategory(valueGrouphigh/low_value) →AssetCategoryAttributeDef(thuộc tính động theo category, unique(categoryId,key)) →AssetModel(specsJSONB, validate động qua Zod build runtime từ AttributeDef).AssetItem.model(string) retrofit sangAssetItem.modelId(FK). Assignmentxong + code-reviewer gate PASS (2026-08-03): CRUD chuẩn +transferAssignment()(đóng Assignment cũ + tạo mới cùng 1 transaction, cố tình không đụngAssetItem.status/License.seatsUsedvì resource không thực sự rảnh giữa chừng khi transfer) — fix 1 gap HIGH code-reviewer phát hiện (trước đó phải gọi 2 request rời, có race window + không rollback).PATCH /api/assignments/[id]dispatch theo body: cóemployeeId→ transfer, córeturnedAt→ return, cả hai → 400. CONFIRMED qua test-runner độc lập: 284/284 test, tsc/lint sạch.- UI cơ bản đã có (nhánh
worktree-ui-basic, đã merge vàofeat/assignment, đã push): shared nav + fetch hook + table/select/label/textarea primitives, list+create cho mọi entity, list+create+return choAssignment. Chưa có UI cho transfer (chỉ có API). - Test-infra: fix gốc rễ race condition đã note ở lần cập nhật trước (2026-08-04) — bug lộ ra thật khi lên tới file test thứ 12 (
tests/maintenance-records.test.ts), gâyPrismaClientKnownRequestError: Foreign key constraint violatedkhông ổn định ởbeforeEachcủa 2 file khác dù chạy riêng lẻ vẫn xanh (root cause:process.env.DATABASE_URLbị nhiều file test ghi đè lên cùng 1 biến toàn cục, PrismaClient củasrc/lib/prisma.tssingleton rebind sai DB khi file này chạy đè lên timing của file khác). Fix thật:tests/setup.tsgiờ dùngvi.mock("@/lib/prisma", async () => {...})— mỗi file test tự tạo DB tạm riêng + 1PrismaClientriêng (qua optiondatasourceUrl, không đụngprocess.envtoàn cục nữa) — không còn state nào chia sẻ giữa các file.fileParallelism: falseđã bỏ khỏivitest.config.ts(về defaulttrue). Verify: 4 lầnnpm testliên tiếp qua Docker, cả 4 lần đều 327/327 xanh, không leftovertest_*database nào sau khi chạy xong.
Việc còn dở
- Fix timing side-channel ở
verifyUserCredentials: trả về sớm khi email không tồn tại (bỏ qua bcrypt compare) → thời gian phản hồi khác nhau giữa "email sai" vs "password sai", có thể dò được email tồn tại. Fix: luôn chạybcrypt.compare()kể cả khi không tìm thấy user (so với 1 hash giả cố định). - Cải thiện
scripts/create-user.tsđể password không lộ qua CLI arg (đọc từ stdin/prompt thay vì--password) — chấp nhận được vì chỉ IT admin tự chạy tay, không phải self-registration công khai. - Bump
vitestlên 4.x (fix advisory critical GHSA-5xrq-8626-4rwp — không khai thác được vì project không chạyvitest --ui) — không khẩn cấp. - Rà lại advisory riêng của
next(vài high/moderate — SSRF qua WebSocket upgrade, middleware bypass) khi có dịp. - Test coverage gap:
assets/employees/requestschưa có test malformed JSON body / non-object body (licenses/assignmentsđã có). Button(shadcn) khôngforwardRef(registry hiện tại target React 19), project pin React 18.3.1 — dormant risk, chỉ phát sinh nếu sau này có chỗ truyềnrefvào<Button>.- Finding từ code-reviewer gate cho
Assignment(2026-08-03), cả hai đều dormant/chưa gãy vì chưa có delete endpoint nào vàcreateAssignment()hiện chỉ được gọi qua route đã validate:- XOR (
assetItemId/licenseId) chỉ enforce ở tầng Zod (route boundary), không lặp lại bên trongcreateAssignment()— nếu sau này có caller khác gọi thẳng lib function (script, bulk-import) bỏ qua validate, có thể tạo Assignment vi phạm XOR. - FK
assetItemId/licenseIdtrênAssignmentdùngON DELETE SET NULL, không nhất quán vớiemployeeId(ON DELETE RESTRICT) — nếu sau này có endpoint xoáAssetItem/License, cácAssignmentlịch sử tham chiếu tới nó sẽ bị null hoá cả 2 field, xoá mất thông tin đã từng assign gì (và re-vi-phạm XOR retroactively). Nên đổi sangRESTRICTnhưemployeeIdkhi/nếu thêm delete endpoint. - (LOW, chưa cần fix)
returnedAtkhông validate>= assignedAt; decrementLicense.seatsUsedlúc return không có floor guard (an toàn hiện tại vì luôn đi kèm 1 increment trước đó, nhưng không có defensive floor nếu state drift do can thiệp DB tay).
- XOR (
ConsumableItem(phần catalog, low-value) đã có spec (docs/asset-license-management.md) nhưng chưa build — sẵn sàng giaopmkhi cần. Luồng "xin+nhận" consumable riêng (đơn giản hơn state machineRequest) vẫn BỊ CHẶN chờ stakeholder trả lời 7 câu hỏi mở (xem doc).- Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled.
- UI cho các thao tác update/transfer (hiện chỉ Assignment có return UI, chưa có transfer UI) và cho các state Request kế tiếp — chờ built xong phần trên.
- Auth/phân quyền (ai được approve request, ai được xác nhận nhận hàng/bàn giao) — vẫn flat, chưa có role.
Cập nhật lần cuối: 2026-08-04 (fix root-cause race condition test-infra — per-file PrismaClient qua vi.mock, bật lại fileParallelism)