Files
ubi-dan-itam/CLAUDE.md

23 KiB
Raw Permalink Blame History

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.ts dùng raw pg Client để 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 trong docs/asset-license-management.md. Không còn tailwind.config.ts — theme khai báo thẳng trong CSS qua @theme inline { ... } (src/app/globals.css), postcss.config.js chỉ còn plugin @tailwindcss/postcss (không cần autoprefixer riêng nữa). shadcn config: style new-york-v4, base color neutral, 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ùng radix-ui (package hợp nhất, không phải @radix-ui/react-slot riêng lẻ nữa) và tw-animate-css (thay tailwindcss-animate). globals.css còn @import "shadcn/tailwind.css" — 1 file CSS runtime do chính package shadcn (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ới components.json nà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>.ts mỗ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àm toRecord() map từ shape Prisma (camelCase) sang shape public API (snake_case — vd employee_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), check typeof 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) — import prisma từ đó, 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) → Prisma enum, không phải String + 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.css qua @theme inline { --color-*: var(--*); ... } — không còn tailwind.config.ts. Biến CSS ở :root/.dark đã là giá trị oklch(...) đầy đủ, @theme inline chỉ alias thẳng qua var(--x), không bọc thêm hsl(...)/rgb(...) (bài học từ bug hsl(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.ts mirror theo lib file, chạy trên Postgres thật qua DB tạm per-test-file (xem tests/setup.ts) — không mock DB. beforeEach dọn bằng prisma.<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 (vd feat: 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:
    1. feat: add <Entity> Prisma model + migration
    2. feat: add <Entity> validation + CRUD (src/lib/<entity>.ts)
    3. feat: add <Entity> API routes
    4. test: 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 qua git 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ùng git 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ọi npm install/npm test/tsc phải chạy qua Docker (node:20 container, 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 node trước), chạy npm install/npm test/tsc trự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 (service db, network asset-license-mgmt-net, image postgres: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 khi npm 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ẳng npm install && npm test — Postgres vẫn phải resolve qua db nếu chạy trong Docker network, hoặc localhost/port map nếu compose có publish port (hiện docker-compose.yml khô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 (xem tests/setup.ts), không đụng vào database dev chính.
    • DATABASE_URL mẫu nằm ở .env.example — copy thành .env (đã gitignore) nếu cần chỉnh local, riêng cho từng máy.
  • 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 (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"):

  1. 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. 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. 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. 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.
  5. 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.
  6. 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

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, MaintenanceRecord. 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 qua src/middleware.ts (default-deny; src/auth.config.ts tách riêng vì Credentials provider dùng bcryptjs không chạy được Edge Runtime; API route tự check auth() vì matcher loại trừ /api). Deploy lên homelab (2026-08-02) tại ubi.toanhoang.work — gotcha apk add openssl đã ghi ở /opt/homelab/CLAUDE.md, không lặp lại ở đây.
  • Request.employeeId retrofit string→FK (2026-07-31): thay đổi hành vi API có chủ ý — employee_id giờ bắt buộc UUID hợp lệ + phải trỏ Employee có thật, trả 400 sạch thay vì 500 nếu không (P2003).
  • License.vendor retrofit string→vendorId FK (2026-08-02), cùng lúc PurchaseOrder (gộp Receipt) ra đời với purchaseOrderId (nullable FK) ở cả AssetItem/License/Request (3 chiều traceability khác nhau — xem docs/asset-license-management.md).
  • Phân cấp tài sản (2026-08-03): AssetCategory (valueGroup high/low_value) → AssetCategoryAttributeDef (thuộc tính động theo category, unique (categoryId,key)) → AssetModel (specs JSONB, validate động qua Zod build runtime từ AttributeDef). AssetItem.model (string) retrofit sang AssetItem.modelId (FK).
  • Assignment xong + 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 đụng AssetItem.status/License.seatsUsed vì 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ào feat/assignment, đã push): shared nav + fetch hook + table/select/label/textarea primitives, list+create cho mọi entity, list+create+return cho Assignment. Chưa có UI cho transfer (chỉ có API).
  • UI cho MaintenanceRecord xong (2026-08-04, src/app/maintenance-records/page.tsx): list+create+close, mirror per-row close mini-form pattern của Assignment's return UI; targetStatus select tái dùng closed AssetItemStatus set client-side (cùng precedent với licenses page's licenseType select). tsc/lint sạch, 334/334 test.
  • UI transfer cho Assignment xong (2026-08-04, src/app/assignments/page.tsx): per-row employee <Select> + nút Transfer cạnh nút Return sẵn có (chỉ hiện ở row active — returned_at null), PATCH {employeeId} — route đã tự dispatch sang transferAssignment(). Không có thay đổi lib/API, tsc/lint sạch.
  • MaintenanceRecord xong + code-reviewer gate PASS (2026-08-04): entity mới track sửa chữa/bảo hành cho AssetItem (spec BA+stakeholder, xem docs/asset-license-management.md) — type (enum repair/warranty/preventive), vendorId (tái dùng Vendor, nullable), không track cost. Mutator đầu tiên ghi AssetItem.status ngoài Assignment: mở record → auto status=repair (chặn 400 nếu AssetItem đang retired hoặc có Assignment active hoặc đã có record khác đang mở); đóng record (PATCH, set endDate+resolutionNotes) nhận targetStatus bắt buộc, giới hạn chỉ in_stock/retired (không cho assigned/repair — tránh tạo AssetItem.status=assigned mồ côi không có Assignment tương ứng, fix sau code-reviewer MEDIUM finding). Code-reviewer tìm 1 gap HIGH (guard updateMany ban đầu dùng status: {not:"retired"} thay vì pin đúng status:"in_stock" như Assignment, cho phép race mở 2 record cùng lúc / đụng độ với Assignment) — đã fix, giờ mirror đúng pattern atomic guard của createAssignment(). CONFIRMED qua test-runner độc lập: 327/327 test (chạy lại 3 lần liên tiếp), tsc/lint sạch.
  • Test-infra: fix gốc rễ race condition (2026-08-04) — bug lộ ra thật khi lên tới file test thứ 12 (tests/maintenance-records.test.ts), gây PrismaClientKnownRequestError: Foreign key constraint violated không ổn định ở beforeEach của 2 file khác dù chạy riêng lẻ vẫn xanh (root cause: process.env.DATABASE_URL bị nhiều file test ghi đè lên cùng 1 biến toàn cục, PrismaClient của src/lib/prisma.ts singleton rebind sai DB khi file này chạy đè lên timing của file khác). Fix thật: tests/setup.ts giờ dùng vi.mock("@/lib/prisma", async () => {...}) — mỗi file test tự tạo DB tạm riêng + 1 PrismaClient riêng (qua option datasourceUrl, không đụng process.env toàn cục nữa) — không còn state nào chia sẻ giữa các file. fileParallelism: false đã bỏ khỏi vitest.config.ts (về default true).

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ạy bcrypt.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 vitest lên 4.x (fix advisory critical GHSA-5xrq-8626-4rwp — không khai thác được vì project không chạy vitest --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/requests chưa có test malformed JSON body / non-object body (licenses/assignments đã có).
  • Button (shadcn) không forwardRef (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ền ref và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 trong createAssignment() — 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/licenseId trên Assignment dùng ON DELETE SET NULL, không nhất quán với employeeId (ON DELETE RESTRICT) — nếu sau này có endpoint xoá AssetItem/License, các Assignment lị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 sang RESTRICT như employeeId khi/nếu thêm delete endpoint.
    • (LOW, chưa cần fix) returnedAt không validate >= assignedAt; decrement License.seatsUsed lú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).
  • Finding LOW từ code-reviewer gate cho MaintenanceRecord (2026-08-04), chưa fix (chấp nhận được, dormant): closeMaintenanceRecord()'s update cuối cùng lên AssetItem.status là update() thường, không có WHERE-guard theo trạng thái hiện tại — cùng loại rủi ro dormant như Assignment.returnedAt/seatsUsed ở trên, chỉ phát sinh nếu có flow khác ghi AssetItem.status đồng thời lúc đóng record (hiện chưa có).
  • ConsumableItem (phần catalog, low-value) đã có spec (docs/asset-license-management.md) nhưng chưa build — sẵn sàng giao pm khi cần. Luồng "xin+nhận" consumable riêng (đơn giản hơn state machine Request) 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 state Request kế tiếp (hiện chỉ có Draft) — chờ spec/build state machine ở trên trước. (Lưu ý: có thể đã có tiến triển từ 1 phiên Claude Code khác chạy song song — kiểm tra git status/working tree trước khi bắt đầu để tránh làm trùng.)
  • 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 (UI cho MaintenanceRecord xong — list+create+close; UI transfer cho Assignment xong)