Author SHA1 Message Date
toanhoang e30489297c docs: note Assignment transfer UI landing in CLAUDE.md 2026-08-04 10:44:05 -04:00
toanhoang d700eaa6a9 feat: add transfer UI for Assignment
Per-row employee <Select> + Transfer button alongside the existing Return
button, shown for active (returned_at null) rows. PATCHes
/api/assignments/[id] with {employeeId}, which the route already dispatches
to transferAssignment() vs returnAssignment() based on body shape.
2026-08-04 10:43:39 -04:00
toanhoang 713dfad364 docs: note MaintenanceRecord UI landing in CLAUDE.md 2026-08-04 10:24:27 -04:00
toanhoang 74e78322b4 feat: add list+create+close UI for MaintenanceRecord
Mirrors assignments/page.tsx's per-row close mini-form pattern; targetStatus
select reuses the closed AssetItemStatus set client-side, same precedent as
licenses page's licenseType select.
2026-08-04 10:23:50 -04:00
toanhoang 651ff2d626 fix: read password from stdin prompt instead of CLI arg in create-user script
Avoids leaking the plaintext password via shell history/`ps`/process
listings. --email/--name stay as CLI flags (not sensitive); password is now
read via a masked interactive prompt using only Node built-ins (readline +
raw-mode stdin), no new dependency.
2026-08-04 06:42:35 -04:00
toanhoangandClaude Sonnet 5 b702528e19 fix: change Assignment.assetItemId/licenseId FK onDelete to RESTRICT
Was implicitly SET NULL (Prisma default for nullable FK columns),
inconsistent with employeeId's RESTRICT. Once a delete endpoint for
AssetItem/License exists, SET NULL would silently null out historical
Assignment rows on delete, erasing what was assigned and re-violating
the assetItemId/licenseId XOR retroactively. No delete endpoint exists
yet, so no real data was ever affected by the old behavior.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 06:41:13 -04:00
toanhoang be47eb69d3 test: add malformed/non-object body tests for assets, employees, requests
Closes the coverage gap noted in CLAUDE.md's "Việc còn dở" — mirrors the
existing pattern in tests/licenses.test.ts and tests/assignments.test.ts for
POST /api/assets, POST /api/employees, POST /api/requests (the only body-
accepting methods on these three routes; none have a PATCH route yet).
2026-08-04 06:40:13 -04:00
toanhoang 2a0acd11a9 fix: run dummy bcrypt.compare on unknown user to prevent timing side-channel 2026-08-04 06:39:08 -04:00
toanhoang 44dd33dd3b docs: note MaintenanceRecord landing + code-review gate PASS in CLAUDE.md
Records the entity build (4 commits), the HIGH race-condition finding
and MEDIUM targetStatus finding from code-reviewer, and their fixes.
2026-08-04 06:36:05 -04:00
toanhoang 7e1351bde2 fix: isolate Prisma Client per test file, re-enable fileParallelism
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.
2026-08-04 06:04:23 -04:00
toanhoang 91624d5f07 fix: close MaintenanceRecord race condition + restrict targetStatus
createMaintenanceRecord()'s AssetItem status guard pinned status:{not:"retired"}
instead of status:"in_stock", so two concurrent opens (or an open racing a
concurrent createAssignment()) could both pass the guard, producing two open
MaintenanceRecords or an orphaned active Assignment. Guard now pins "in_stock"
exactly, mirroring createAssignment()'s own atomic guard.

closeMaintenanceRecord()'s targetStatus accepted any of the 4 AssetItemStatus
values, including "assigned" — which sets AssetItem.status without creating a
corresponding Assignment row, desyncing Assignment (source of truth for who
holds what) from AssetItem.status with no way back. Restricted to
in_stock|retired, the only two outcomes a closed repair can actually resolve
to.
2026-08-04 06:01:34 -04:00
toanhoang dfbee1a00d test: add MaintenanceRecord API tests 2026-08-04 05:35:37 -04:00
toanhoang 7f4a727669 feat: add MaintenanceRecord API routes
POST/GET /api/maintenance-records and PATCH /api/maintenance-records/[id]
(close action), mirroring the Assignment route pattern exactly.
2026-08-04 05:32:21 -04:00
toanhoang 67fe335e76 feat: add MaintenanceRecord validation + CRUD (src/lib/maintenance-records.ts)
createMaintenanceRecord() opens a record and flips AssetItem.status to
"repair" inside one transaction, rejecting on missing/retired AssetItem,
an active Assignment, or an already-open record for the same item.
closeMaintenanceRecord() sets endDate/resolutionNotes and applies a
caller-supplied targetStatus (never defaulted) to the AssetItem, guarding
double-close and endDate>=startDate. Mirrors src/lib/assignments.ts's
Zod-validate + $transaction + WHERE-guarded updateMany race-closing pattern.
2026-08-04 05:29:26 -04:00
toanhoang 5089bd6dfc feat: add MaintenanceRecord Prisma model + migration
New entity tracking repair/warranty/preventive maintenance jobs for an
AssetItem. Reuses Vendor (nullable) for the repair location, no cost
field (mirrors PurchaseOrder). "At most 1 open record per AssetItem"
invariant left to the application layer, mirroring Assignment.
2026-08-04 05:25:22 -04:00
toanhoang d557694f77 docs: chốt spec MaintenanceRecord (BA + stakeholder 2026-08-04)
Entity mới track sửa chữa/bảo hành cho AssetItem, hiện thực hoá case
"in repair" đã note từ trước. Field, type enum, đồng bộ AssetItem.status,
và quyết định không track cost đều đã chốt với stakeholder — sẵn sàng
giao pm chia task.
2026-08-04 04:18:47 -04:00
toanhoang e1ad9da72d docs: consolidate CLAUDE.md status/backlog, add Assignment review findings
Condensed the "Trạng thái hiện tại"/"Việc còn dở" sections — most of the
detailed per-entity narrative was either resolved, superseded by a later
change, or derivable from git log, so it's trimmed to what isn't:
decisions, gotchas, and still-open risks. Added the UI (worktree-ui-basic
merge), the Assignment transfer fix, and the two dormant findings
(XOR defense-in-depth, Assignment FK onDelete) from the code-reviewer gate.
2026-08-03 20:27:52 -04:00
toanhoang d6cea577c9 fix: scope vitest to tests/ dir to avoid picking up stray worktree checkouts
Vitest's default glob isn't excluded for .claude/, so a sibling worktree
under .claude/worktrees/ (e.g. worktree-ui-basic) got silently collected
and run against this repo's current src/ via the "@" alias, doubling the
test count and running a stale test file against current code under a
shared identity. Found by an independent test-runner pass while verifying
the Assignment transfer fix.
2026-08-03 19:54:49 -04:00
20 changed files with 2334 additions and 114 deletions
+27 -39
View File
@@ -80,48 +80,36 @@ Với mỗi mục tiêu nghiệp vụ stakeholder đưa ra (vd "thêm tracking s
## Trạng thái hiện tại ## Trạng thái hiện tại
- **Task 1 xong**: đã bootstrap Next.js (TS, App Router) project thật, entity `Request` (chỉ state `Draft`) với `POST/GET /api/requests`. 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).
- **Migrate SQLite → Postgres xong**: CONFIRMED pass qua test-runner độc lập.
- **ESLint setup xong**: CONFIRMED pass sạch qua test-runner độc lập.
- **Retrofit `Request` sang Prisma + Zod xong (2026-07-30)**: `prisma/schema.prisma` (model `Request`, enum `RequestType`), `src/lib/prisma.ts` (singleton client), `src/lib/requests.ts` (Zod schema thay hàm validate tay, Prisma Client thay raw SQL), `scripts/migrate.ts` (gọi `prisma migrate deploy`), `tests/setup.ts`/`tests/requests.test.ts` (schema áp qua `prisma migrate deploy`, dọn dữ liệu qua `prisma.request.deleteMany()`). `src/lib/db.ts` (raw pg Pool cũ) đã xoá. **CONFIRMED qua test-runner độc lập**: 6/6 test pass trên Postgres thật, type-check sạch, lint sạch. Migration SQL đối chiếu khớp 1:1 với schema cũ (chỉ khác `type` từ `TEXT+CHECK` sang Postgres enum — tương đương semantics).
- `npm audit`: 1 "critical" nằm ở `vitest` (devDependency, CVE cần `vitest --ui` đang chạy — project không dùng UI mode nên không khai thác được trong cách dùng hiện tại). Rủi ro thực tế thấp, chưa cần fix gấp — xem "Việc còn dở".
- **Tailwind + shadcn/ui setup xong (2026-07-30, ban đầu trên v3)**: `postcss.config.js`, `src/app/globals.css`, `components.json`, `src/lib/utils.ts` (`cn()`), `src/components/ui/button.tsx` (component mẫu để xác nhận pipeline chạy được). Có 1 bug phát hiện qua verify độc lập (màu token bị bọc `hsl()` chồng lên giá trị `oklch()` sẵn có → CSS không hợp lệ, silent fail lúc render dù build/lint/tsc đều xanh) — đã fix và CONFIRMED qua verify độc lập lần 2.
- **Nâng cấp lên Tailwind v4 + shadcn CLI `@latest` xong (2026-07-30)**: bỏ `tailwind.config.ts` (theme giờ khai trong `globals.css` qua `@theme inline`), đổi `postcss.config.js` sang `@tailwindcss/postcss`, đổi dependency (`radix-ui` thay `@radix-ui/react-slot`, `tw-animate-css` thay `tailwindcss-animate`, thêm `shadcn` làm runtime dependency vì `globals.css` import `shadcn/tailwind.css`), `button.tsx` viết lại theo registry `new-york-v4` hiện tại. **CONFIRMED qua test-runner độc lập**: install/build/tsc/lint sạch, 6/6 test Request vẫn pass (không liên quan tới UI), CSS đã compile xác nhận màu sạch (không còn kiểu bug `hsl(oklch(...))`), không còn gói v3 sót lại.
- **Rủi ro tồn đọng (dormant, chưa gãy)**: `button.tsx` mới bỏ `React.forwardRef` (convention của registry hiện tại là cho React 19), nhưng project đang pin **React 18.3.1** — nếu sau này có chỗ truyền `ref` vào `<Button>`, ref sẽ không được forward đúng cách. Hiện tại không chỗ nào trong code truyền `ref` nên chưa phát sinh lỗi — nhưng cần nhớ khi build UI thật, tránh `<Button ref={...}>` hoặc phải tự thêm lại `forwardRef` nếu cần.
- **Git repo khởi tạo + push lên GitHub xong (2026-07-30)**: `https://github.com/toandev96/ubi-dan-itam.git` (private), remote `origin`/branch `master`. Business context (`docs/asset-license-management.md`) đã move từ `ubisoft/processes/` vào trong repo này cùng ngày, để travel theo code khi clone ở máy khác (vd VM Linux ở nhà qua Proxmox) — không còn phụ thuộc thư mục `processes/` ở workspace ngoài repo nữa cho riêng phần business context của app này.
- **Auth (login nội bộ, không phải SSO công ty) xong (2026-07-31)**: `User` Prisma model (`prisma/schema.prisma`), `src/lib/users.ts` (Zod + `createUser()` + `verifyUserCredentials()`, bcryptjs hash), `scripts/create-user.ts` (tạo account bằng CLI, không có self-registration), Auth.js v5 (`next-auth@^5.0.0-beta.32`, Credentials provider, JWT session, không dùng `@auth/prisma-adapter` vì không cần OAuth/DB session) ở `src/auth.ts` + `src/app/api/auth/[...nextauth]/route.ts`, trang login `src/app/login/page.tsx`. Permission model **flat** (bất kỳ User nào login cũng làm mọi thứ được — quyết định đã chốt, không có `role`). **CONFIRMED qua verify độc lập** (test-runner + security review): 16/16 test pass (6 cũ + 10 mới) trên Postgres thật, tsc/lint/build sạch, không lộ password, bcrypt compare đúng chuẩn constant-time, `AUTH_SECRET` fail-closed thật khi thiếu (đã test thực tế, không fallback giá trị đoán được).
- **Finding nhỏ (chưa block, cần fix sau)**: timing side-channel trong `verifyUserCredentials` — trả về sớm khi email không tồn tại (bỏ qua bcrypt compare), nên request với email sai vs password sai có thời gian phản hồi khác nhau chút, có thể dò được email nào tồn tại. Fix: luôn chạy `bcrypt.compare()` kể cả khi không tìm thấy user (so với 1 hash giả cố định).
- **Finding nhỏ (chấp nhận được, cần track)**: `scripts/create-user.ts --password <giá trị>` lộ password vào shell history/process list — chấp nhận vì chỉ IT admin tự chạy tay, không phải self-registration công khai, nhưng nên cải thiện sau (đọc từ prompt/stdin thay vì argv).
- **Note triển khai**: chạy `next start` (production) cần thêm `AUTH_TRUST_HOST=true` hoặc `AUTH_URL` trong env, nếu không sẽ gặp lỗi `UntrustedHost` — đã thêm vào `.env.example`/`.env` khi deploy thật (xem bullet deploy bên dưới).
- **Route-gating xong (2026-07-31)**: `src/middleware.ts` (protect mọi page route theo default-deny, redirect `/login` nếu chưa đăng nhập, trừ `/login` chính nó — tránh vòng lặp redirect), `src/auth.config.ts` (config Auth.js edge-safe, tách ra khỏi `src/auth.ts` — **không** có Credentials provider vì `authorize()` gọi `verifyUserCredentials()` kéo theo `bcryptjs`, là Node API không chạy được trên Edge Runtime mà `middleware.ts` dùng; `src/middleware.ts` tự tạo instance `NextAuth(authConfig)` riêng, nhẹ, chỉ để decode JWT session cookie — xác nhận qua build thật: trước khi tách, `next build` báo warning bcryptjs/jose bundle vào Edge Runtime (middleware 115kB); sau khi tách, build sạch, middleware còn 78.9kB). `src/auth.ts` giờ spread `authConfig` + thêm Credentials provider thật, dùng cho API route/Server Component (chạy Node runtime).
- `config.matcher` của middleware loại trừ toàn bộ `/api` (không chỉ `/api/auth`) — API route tự check `auth()` lấy session ngay trong handler (không dựa hoàn toàn vào middleware, theo đúng khuyến nghị chính thức của Auth.js: verify session càng gần chỗ fetch data càng tốt). `/api/requests` (`GET`/`POST`) đã thêm check `auth()`, trả `401 {"error":"Unauthorized"}` nếu chưa login — xem `src/app/api/requests/route.ts`.
- `/` (home page placeholder) **có** yêu cầu login — chọn theo nguyên tắc "mặc định chặn hết, allowlist rõ ràng cho cái không cần" thay vì opt-in dễ quên route mới.
- Test: `tests/requests.test.ts` mock `@/auth` (`vi.mock`) thay vì gọi `auth()` thật, vì `auth()` không tham số đọc cookie qua `next/headers` — chỉ hoạt động khi chạy trong request-handling thật của Next.js, không phải khi test import gọi thẳng `GET()`/`POST()` (bypass hoàn toàn Next). Test mặc định mock session hợp lệ (`beforeEach`) để không phá test nghiệp vụ cũ, có riêng 2 test set mock về `null` để xác nhận 401. `middleware.ts` bản thân **không** có test tự động (chạy Edge Runtime, không test được qua vitest theo cách hiện tại) — đã verify tay qua `next build` + `next start` thật (curl xác nhận: `GET /` → 307 → `/login`, `GET /login` → 200 không loop, `GET/POST /api/requests` chưa login → 401 JSON, `GET /api/auth/session` → 200 không bị chặn). **CONFIRMED**: `npx tsc --noEmit`, `npm run lint`, `npm test` (18/18, gồm 2 test 401 mới), `npm run build` đều sạch.
- **`Employee` entity xong + `Request.employee_id` retrofit sang FK thật (2026-07-31)**: `Employee` Prisma model (`prisma/schema.prisma` — `name`, `department` (string đơn giản, chưa tách entity riêng), `managerId` self-relation nullable), `src/lib/employees.ts` (Zod + CRUD, theo đúng pattern chuẩn ở "Coding conventions"), `src/app/api/employees/route.ts` (`POST`/`GET`, gate `auth()` như `/api/requests`), `tests/employees.test.ts` (11 test, Postgres thật). `Request.employeeId` — trước đây là `String` tự do, không có ràng buộc — giờ là FK thật tới `Employee` (`@relation`, migration `20260731075828_relate_request_to_employee`). **Đây là thay đổi hành vi có chủ ý tới API `Request` đã ship trước đó**: `employee_id` trong body `POST /api/requests` giờ **bắt buộc là UUID hợp lệ** (Zod `.uuid()`, không còn nhận string tuỳ ý như `"emp-1"`), và phải **trỏ tới một `Employee` có thật** — nếu không, `createRequest()` bắt lỗi FK violation của Prisma (`P2003`) và trả `400 {"error": "employee_id does not reference an existing employee"}` thay vì lỗi 500 thô. `tests/requests.test.ts` đã cập nhật: mọi test tạo `Request` giờ tạo `Employee` thật trước qua helper `createTestEmployee()` rồi dùng `.id` thật, có thêm 2 test mới cho đúng 2 case bị FK chặn (UUID sai format, UUID đúng format nhưng không tồn tại).
- **Bug hạ tầng test phát hiện + fix trong lượt này**: `tests/setup.ts` tạo DB Postgres tạm riêng cho mỗi file test bằng cách ghi đè `process.env.DATABASE_URL` trong `beforeAll` — cách này **không an toàn** khi Vitest chạy nhiều file test song song (mặc định): timing giữa `beforeAll`/query của 2 file có thể chồng nhau khiến cả 2 cùng thao tác nhầm lên 1 DB tạm. Trước đây không lộ ra vì chưa có 2 file test nào cùng đụng bảng có FK với nhau; lộ ra ngay khi `Request` có FK thật tới `Employee` (`tests/employees.test.ts` và `tests/requests.test.ts` xoá/đọc nhầm dữ liệu của nhau, lặp lại 100% mỗi lần chạy). Fix: `vitest.config.ts` thêm `fileParallelism: false` (chạy tuần tự từng file) — **CONFIRMED**: 31/31 test pass ổn định qua `npm test` (11 employees + 10 requests + 10 auth), `npx tsc --noEmit`/`npm run lint`/`npm run build` đều sạch.
- **`AssetItem` entity xong (2026-07-31)**: `AssetItem` Prisma model + enum `AssetItemStatus` (`in_stock`/`assigned`/`repair`/`retired`, default `in_stock`) — `serialNumber` (unique), `model`, `purchaseDate`, `warrantyEndDate` (nullable — không phải asset nào cũng biết hạn bảo hành lúc nhập), migration `20260731090347_add_asset_item`. `src/lib/assets.ts` (Zod + CRUD, theo đúng pattern chuẩn ở "Coding conventions", bắt lỗi Prisma `P2002` khi `serialNumber` trùng và trả `400 {"error": "serialNumber already exists"}` thay vì lỗi 500 thô), `src/app/api/assets/route.ts` (`POST`/`GET`, gate `auth()` như `/api/employees`), `tests/assets.test.ts` (11 test, Postgres thật). Asset mới luôn tạo ở status `in_stock` (không nhận status từ client lúc tạo, như `Request` luôn tạo `Draft`) — các state khác (assigned/repair/retired) sẽ set qua thao tác sau, chưa có endpoint riêng. Chưa có quan hệ tới `Vendor`/`Assignment` (2 entity đó chưa build). **CONFIRMED qua test-runner độc lập**: 42/42 test pass (11 assets + 11 employees + 10 requests + 10 auth) trên Postgres thật, `npx tsc --noEmit`/`npm run lint`/`npm run build` đều sạch.
- **Deploy lên homelab xong (2026-08-02)**: `Dockerfile` (single-stage `node:20-alpine`, `npm ci` + `npm run build` + `npm start`) và service `app` mới trong `docker-compose.yml` (port `3002:3000`, cùng network `asset-license-mgmt` với `db` để resolve hostname `db`). **Gotcha đã gặp + fix**: `node:20-alpine` không có `libssl` sẵn → Prisma không detect được OpenSSL lúc `npm ci` (postinstall chạy `prisma generate`), tự chọn nhầm engine binary (`openssl-1.1.x`) — build vẫn xanh (route API là dynamic, không chạm Prisma lúc build) nhưng **crash lúc runtime** khi query thật. Fix: `RUN apk add --no-cache openssl` trong `Dockerfile` **trước** `npm ci`. Đã verify bằng cách gọi `prisma.employee.count()` thật bên trong container sau khi rebuild — pass. Expose ra internet qua `ubi.toanhoang.work` theo đúng flow `add-service` skill ở `/opt/homelab/CLAUDE.md`: subdomain qua `cloudflare-ddns-ubi`, NPM proxy host (`10.8.2.3:3002`, SSL forced) + Let's Encrypt, Unbound Host Override cho LAN. `AUTH_TRUST_HOST=true` thêm vào `.env`/`.env.example` (đóng TODO cũ). **Chưa có user nào trong DB** — chạy `docker compose exec app npm run user:create` để tạo account đầu tiên trước khi ai có thể login thật.
- **`License` entity xong (2026-08-02)**: `License` Prisma model + enum `LicenseType` (`perpetual`/`subscription`) — `name`, `vendor` (string đơn giản, chưa tách entity `Vendor` riêng, theo đúng precedent `Employee.department`), `seatsTotal` (bắt buộc `> 0`), `seatsUsed` (default `0`, client không set được lúc tạo — giống `AssetItem.status` luôn tạo `in_stock`), `expiryDate` (nullable — perpetual không hết hạn), migration `20260802123955_add_license`. `src/lib/licenses.ts` (Zod + CRUD), `src/app/api/licenses/route.ts` (`POST`/`GET`, gate `auth()` như các entity khác), `tests/licenses.test.ts` (27 test, cả lib-level lẫn route-level, có thêm test malformed JSON/non-object body — đóng luôn 1 phần gap coverage cũ, nhưng chỉ cho `licenses`, chưa retrofit lại cho `assets`/`employees`/`requests`). **CONFIRMED qua test-runner độc lập**: 69/69 test pass, tsc/lint sạch. **Review qua `code-reviewer` (trigger: seat-count logic)**: không có bug chặn, nhưng có finding cần nhớ — `createLicense()` trả `Promise<LicenseRecord>` trần (không theo pattern `{ok,value}|{ok,error}` như `createAssetItem()`); vô hại bây giờ vì `License` chưa có unique constraint nào, nhưng nếu sau này thêm `@@unique` (vd trên `name`+`vendor`) mà không đồng thời thêm xử lý lỗi `P2002` ở `createLicense()`, request trùng sẽ trả 500 thô thay vì `400` sạch như các entity khác. Chưa fix vì hiện tại xử lý lỗi cho constraint không tồn tại là dư thừa — cần nhớ làm cùng lúc nếu/khi thêm unique constraint. - 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ở ## Việc còn dở
- `fileParallelism: false` (fix race condition test-infra) là fix đúng nhưng "cùn" — chặn song song **toàn bộ** suite thay vì chỉ fix gốc rễ (`tests/setup.ts` ghi đè `process.env.DATABASE_URL` toàn cục thay vì truyền connection string riêng cho từng Prisma Client instance). Test suite sẽ chạy chậm dần tuyến tính khi thêm entity mới (`AssetItem`, `License`...). Cân nhắc refactor `tests/setup.ts` để mỗi file tự có Prisma Client riêng trỏ đúng DB tạm của mình, rồi bật lại parallelism — chưa gấp ở quy mô hiện tại (3 file, ~15s). - Fix timing side-channel ở `verifyUserCredentials`: 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).
- 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`) — chấp nhận được vì chỉ IT admin tự chạy tay, không phải self-registration công khai.
- Cải thiện `scripts/create-user.ts` để password không lộ qua CLI arg (đọc từ stdin/prompt thay vì `--password`). - 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.
- 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` (vài high/moderate — SSRF qua WebSocket upgrade, middleware bypass) khi có dịp.
- Rà lại advisory riêng của `next` (prod dependency, có vài high/moderate — vd SSRF qua WebSocket upgrade, middleware bypass) khi có dịp, tách biệt khỏi đợt retrofit Prisma/Zod này. - Test coverage gap: `assets`/`employees`/`requests` chưa có test malformed JSON body / non-object body (`licenses`/`assignments` đã có).
- 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` (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>`.
- `Button` (shadcn) không `forwardRef`, React đang pin 18 — dormant risk, xem note ở "Trạng thái hiện tại". - **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:**
- **`Vendor`/`PurchaseOrder`(gộp `Receipt`)/`Assignment`/`AssetCategoryAttributeDef` (2026-08-02): spec đầy đủ ở `docs/asset-license-management.md`, đã có task list phụ thuộc-đúng do `pm`-pass sequencing (2026-08-02), sẵn sàng giao `feature-implementer` theo đúng thứ tự dưới đây (3 lane độc lập, có thể xen kẽ):** - 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.
- **Lane A (Vendor → License.vendorId → PurchaseOrder → purchaseOrderId x3)**: (1-4) `Vendor` entity chuẩn 4 commit (model+migration/lib/API/test), không phụ thuộc gì. (5) Retrofit `License.vendor` (string) → `License.vendorId` (FK) — **1 commit gộp** (schema+migration+lib+test), theo đúng tiền lệ retrofit `Request.employeeId` (commit `49d96b7`, không tách lớp như entity mới vì tách ra sẽ để lại state nửa vời không chạy được) — tiện thể fix luôn finding cũ của `code-reviewer`: `createLicense()` chưa theo pattern `{ok,value}|{ok,error}`, giờ **bắt buộc** phải thêm vì `vendorId` là FK mới (cần bắt `P2003`). Phụ thuộc (1). (6-9) `PurchaseOrder` entity chuẩn 4 commit (`vendorId` FK, `receivedBy` FK→`Employee`, Zod `.refine()` cho ràng buộc `receivedAt`/`receivedBy` cùng null hoặc cùng có giá trị, bắt `P2003` cho cả 2 FK, theo đúng pattern `{ok,error}` ngay từ đầu) — phụ thuộc (1) `Vendor` + `Employee` (đã có). (10) `AssetItem.purchaseOrderId` — 1 commit gộp (nullable FK). (11) `License.purchaseOrderId` — 1 commit gộp **riêng** với task (5) dù cùng file, vì trigger phụ thuộc khác nhau (`Vendor` vs `PurchaseOrder`) — gộp chung sẽ vi phạm nguyên tắc 1-commit-1-thay-đổi-nghiệp-vụ. (12) `Request.purchaseOrderId` — 1 commit gộp. (10)(11)(12) đều chỉ phụ thuộc (6), độc lập lẫn nhau, thứ tự giữa 3 task này không quan trọng. - 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.
- **Lane B (AssetCategory → AssetCategoryAttributeDef + AssetModel → wire dynamic validation → AssetItem.modelId)**: **gap phát hiện khi sequencing** — `AssetCategory`/`AssetModel`/`ConsumableItem` đã có spec từ round BA trước (2026-08-02, xem "Phân loại tài sản" trong doc) nhưng **CHƯA build** (không có trong `prisma/schema.prisma` hiện tại, `AssetItem.model` vẫn là string tự do) — phải làm trước thì `AssetCategoryAttributeDef` mới có ý nghĩa. (13-16) `AssetCategory` entity chuẩn 4 commit (`name`, `valueGroup` enum `high_value`/`low_value`), không phụ thuộc gì. (17-20) `AssetCategoryAttributeDef` entity chuẩn 4 commit (`categoryId` FK, `key`, `label`, `dataType` enum, `required`, `sortOrder`, unique `(categoryId,key)` → bắt `P2002`) — phụ thuộc (13), độc lập với `AssetModel`. (21-24) `AssetModel` entity chuẩn 4 commit (`categoryId` FK + business rule "category phải `valueGroup=high_value`" — cần lookup DB, không phải Zod thuần; `manufacturer`, `name`, `specs` JSONB — ở task này `specs` chỉ validate lỏng là object hợp lệ, CHƯA khớp động theo `AssetCategoryAttributeDef`) — phụ thuộc (13), độc lập với (17-20). (25) Wire dynamic specs validation vào `AssetModel` create/update — **1 commit riêng**, không gộp vào (22), vì đây là logic mới/rủi ro (build Zod schema runtime từ `AssetCategoryAttributeDef`, map `dataType`→`z.string()/number()/boolean()/coerce.date()`, từ chối key lạ, bắt buộc key `required`) — phụ thuộc (18) và (22). (26) Retrofit `AssetItem.model` (string) → `AssetItem.modelId` (FK) — 1 commit gộp, phụ thuộc (21) (không cần đợi (25)). - (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).
- **Lane C (`Assignment`, độc lập hoàn toàn với Lane A/B — chỉ cần `Employee`+`AssetItem`+`License` đã có sẵn)**: (27) model+migration (`employeeId` FK, `assetItemId`/`licenseId` nullable FK, `assignedAt`, `returnedAt` nullable, `signedAt` nullable). (28) lib validation+CRUD — rủi ro nhất trong cả 4 entity: Zod `.refine()` XOR `assetItemId`/`licenseId`, chặn >1 `Assignment` active/`AssetItem`, transaction tăng/giảm `License.seatsUsed` kèm chặn overbook (`seatsUsed >= seatsTotal`), đồng bộ `AssetItem.status` (assigned/in_stock), helper transfer (đóng cũ+mở mới cùng 1 transaction). (29) API routes — **cần quyết định shape mới**: đây là entity đầu tiên cần thao tác "update" (return/transfer) trong khi codebase hiện tại **mọi entity đều chỉ có POST(create)+GET(list), chưa có route update nào** — đề xuất mặc định `POST /api/assignments` (assign/transfer) + `PATCH /api/assignments/[id]` (return, nhận `{returnedAt}}`), cần xác nhận lại khi giao task cho `pm`/`feature-implementer` vì không có tiền lệ. (30) tests. **(31) Bắt buộc code-reviewer pass riêng cho Assignment (diff 27-30) trước khi coi là xong** — đúng rule ở "Multi-agent dev workflow" (state-mutation-với-invariant, seat-count) — không phải 1 commit, là 1 gate. - **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ó).
- **2 câu hỏi mở đã được stakeholder trả lời (2026-08-02, xem "Assignment" trong `docs/asset-license-management.md`) — Lane C hết bị chặn**: (a) `createAssignment()` **chặn** nếu `AssetItem.status` khác `in_stock` (400). (b) Route update dùng `PATCH /api/<entity>/[id]` (áp dụng cho cả `Assignment` và `PurchaseOrder`), không dùng action-based sub-route. - `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).
- `ConsumableItem` (phần catalog, KHÔNG phải luồng "xin+nhận" — luồng đó vẫn BỊ CHẶN chờ 7 câu hỏi mở, xem doc): độc lập với toàn bộ 3 lane trên (chỉ cần `AssetCategory`), sẵn sàng cho `pm` chia task riêng nếu stakeholder muốn gộp vào cùng đợt — không đưa vào sequencing này vì nằm ngoài phạm vi 4 việc được giao lần này. - Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled.
- 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.)
- 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) — vẫn flat, chưa có role.
- 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-08-02 (deploy lên homelab ubi.toanhoang.work, License entity, task sequencing cho Vendor/PurchaseOrder/Assignment/AssetCategoryAttributeDef) Cập nhật lần cuối: 2026-08-04 (UI cho `MaintenanceRecord` xong — list+create+close; UI transfer cho `Assignment` xong)
+27 -2
View File
@@ -34,6 +34,7 @@ Theo dõi toàn bộ vòng đời tài sản/license: **Request → Approval →
| `AssetModel` | id, category (FK → `AssetCategory`, bắt buộc, phải thuộc nhóm `high_value`), `manufacturer` (vd "Dell"), `name` (vd "Latitude 5420"), `specs` (JSONB — giá trị thật của các thuộc tính động, xem `AssetCategoryAttributeDef`) — tách riêng 2 field `manufacturer`/`name`, không gộp chung 1 string, để filter/report theo hãng. Nằm giữa `AssetCategory` và `AssetItem` (1 model có nhiều `AssetItem` serial khác nhau). Xem "Phân loại tài sản" bên dưới | | `AssetModel` | id, category (FK → `AssetCategory`, bắt buộc, phải thuộc nhóm `high_value`), `manufacturer` (vd "Dell"), `name` (vd "Latitude 5420"), `specs` (JSONB — giá trị thật của các thuộc tính động, xem `AssetCategoryAttributeDef`) — tách riêng 2 field `manufacturer`/`name`, không gộp chung 1 string, để filter/report theo hãng. Nằm giữa `AssetCategory` và `AssetItem` (1 model có nhiều `AssetItem` serial khác nhau). Xem "Phân loại tài sản" bên dưới |
| `AssetCategoryAttributeDef` | id, `categoryId` (FK → `AssetCategory`), `key` (slug, vd `ram_gb`), `label` (tên hiển thị, vd "RAM (GB)"), `dataType` (enum đóng: `string`/`number`/`boolean`/`date`), `required` (bool), `sortOrder` (int) — định nghĩa thuộc tính riêng theo từng category, giá trị thật nằm ở `AssetModel.specs`. Unique `(categoryId, key)`. Xem "Phân loại tài sản" bên dưới | | `AssetCategoryAttributeDef` | id, `categoryId` (FK → `AssetCategory`), `key` (slug, vd `ram_gb`), `label` (tên hiển thị, vd "RAM (GB)"), `dataType` (enum đóng: `string`/`number`/`boolean`/`date`), `required` (bool), `sortOrder` (int) — định nghĩa thuộc tính riêng theo từng category, giá trị thật nằm ở `AssetModel.specs`. Unique `(categoryId, key)`. Xem "Phân loại tài sản" bên dưới |
| `ConsumableItem` | phụ kiện **low-value/consumable** — tên, category (bắt buộc, phải thuộc nhóm `low_value`, trỏ thẳng `AssetCategory` — **không** qua `AssetModel`, khái niệm "model" chỉ áp dụng cho asset serial hóa), số lượng tồn kho (`quantityOnHand`). **KHÔNG serial hóa, KHÔNG gắn Assignment cá nhân** — chỉ track theo số lượng. Xem "Phân loại tài sản" bên dưới | | `ConsumableItem` | phụ kiện **low-value/consumable** — tên, category (bắt buộc, phải thuộc nhóm `low_value`, trỏ thẳng `AssetCategory` — **không** qua `AssetModel`, khái niệm "model" chỉ áp dụng cho asset serial hóa), số lượng tồn kho (`quantityOnHand`). **KHÔNG serial hóa, KHÔNG gắn Assignment cá nhân** — chỉ track theo số lượng. Xem "Phân loại tài sản" bên dưới |
| `MaintenanceRecord` | **(spec BA 2026-08-04, đã chốt đủ với stakeholder — sẵn sàng cho `pm`)** khai báo sửa chữa/bảo hành cho 1 `AssetItem` cụ thể — `assetItemId` (FK, bắt buộc), `issueDescription`, `resolutionNotes` (nullable), `startDate`, `endDate` (nullable — null = đang sửa), `type` (enum: `repair`/`warranty`/`preventive`), `vendorId` (FK → `Vendor`, nullable — null = tự sửa nội bộ). Không track `cost`. Xem "MaintenanceRecord" bên dưới |
## Luồng trạng thái Request ## Luồng trạng thái Request
@@ -116,6 +117,29 @@ Entity mới, độc lập với `AssetModel`.
- Tạo `Assignment` bị chặn nếu `AssetItem.status` khác `in_stock` (400). - Tạo `Assignment` bị chặn nếu `AssetItem.status` khác `in_stock` (400).
- `License.seatsUsed` tăng/giảm đồng bộ trong 1 transaction khi assign/return; assign bị chặn nếu `seatsUsed >= seatsTotal`. - `License.seatsUsed` tăng/giảm đồng bộ trong 1 transaction khi assign/return; assign bị chặn nếu `seatsUsed >= seatsTotal`.
## MaintenanceRecord (spec BA + stakeholder, chốt 2026-08-04 — sẵn sàng cho `pm`)
Xuất phát từ yêu cầu stakeholder: "muốn thêm 1 menu maintenance — khai báo sửa chữa cho từng `AssetItem`, kể cả bảo hành." Đây là **entity mới** (không phải mở rộng field trên `AssetItem`), hiện thực hoá case đã note từ trước ở "Case đặc biệt cần xử lý" bên dưới: "Asset hỏng → gửi RMA/sửa chữa → cần state 'in repair' tách khỏi 'assigned'" — `AssetItemStatus.repair` đã có sẵn trong enum (`prisma/schema.prisma`) từ khi `AssetItem` được tạo, nhưng chưa có entity/flow nào thực sự dùng tới giá trị đó cho tới nay. `MaintenanceRecord` sẽ là mutator **đầu tiên** của `AssetItem.status` ngoài `Assignment` — `AssetItem` hiện chưa có update endpoint nào cả, nên phần đồng bộ status dưới đây cũng ngầm là spec cho phần ghi `AssetItem.status` từ `MaintenanceRecord` (không phải 1 update endpoint chung chung cho `AssetItem`).
**Field:**
- `id`, `assetItemId` (FK → `AssetItem`, bắt buộc), `type` (Postgres enum: `repair` | `warranty` | `preventive`), `vendorId` (FK → `Vendor`, nullable — null nghĩa là tự sửa nội bộ, không qua vendor ngoài), `issueDescription` (text, bắt buộc — mô tả vấn đề/lý do gửi sửa), `resolutionNotes` (text, nullable — set khi đóng record, mô tả kết quả xử lý), `startDate` (Date, bắt buộc — ngày gửi đi sửa/bắt đầu), `endDate` (Date, nullable — ngày nhận lại/hoàn tất; null = đang sửa), `createdAt`.
- Không track `cost` — theo đúng tiền lệ `PurchaseOrder` (đã chốt trước đó không track tiền, xem mục "PurchaseOrder").
- Không có field `status` riêng trên `MaintenanceRecord` — suy ra "đang sửa" (`endDate IS NULL`) vs "đã xong" (`endDate` có giá trị) từ chính `endDate`, đúng tiền lệ `PurchaseOrder.receivedAt` (không track trạng thái trùng lặp).
- Chỉ áp dụng cho `AssetItem` (hardware) — `License` không có khái niệm sửa chữa vật lý, nên **không** cần cơ chế XOR như `Assignment` (`assetItemId` là target duy nhất, luôn bắt buộc).
- `vendorId` tái dùng entity `Vendor` hiện có (nơi mua hàng) — chấp nhận gộp chung khái niệm "đối tác ngoài" cho cả mua hàng lẫn sửa chữa, không tạo bảng riêng.
- 1 `AssetItem` chỉ có tối đa 1 `MaintenanceRecord` **đang mở** (`endDate IS NULL`) tại 1 thời điểm — không thể gửi cùng lúc 2 nơi sửa. Đủ hiển nhiên về mặt vật lý (1 item vật lý không thể ở 2 nơi sửa cùng lúc) để chốt thẳng, không cần hỏi — mirror invariant "tối đa 1 `Assignment` active" đã có.
- `endDate` (nếu có) phải `>= startDate` — validate ở tầng Zod ngay từ đầu, tránh lặp lại gap đã note ở "Việc còn dở" của `../CLAUDE.md` (`Assignment.returnedAt` hiện chưa validate `>= assignedAt`).
- Không track "người tạo/ghi nhận" qua `User` — không có tiền lệ nào trong codebase hiện tại gắn business entity với `User` (chỉ dùng `User` cho login). Nếu sau này cần biết "ai gửi đi sửa", đó là field bổ sung riêng, không phải phần MVP này.
**Đồng bộ `AssetItem.status`:**
- **Mở** `MaintenanceRecord` (`POST`, chưa có `endDate`): tự động chuyển `AssetItem.status` sang `repair` trong cùng transaction (mirror cách `Assignment` đồng bộ `AssetItem.status`/`License.seatsUsed`).
- Nếu `AssetItem` đó đang có 1 `Assignment` **active** (`returnedAt IS NULL`) → **chặn tạo** `MaintenanceRecord`, trả `400` — bắt buộc phải return `Assignment` trước, giống cách `createAssignment()` hiện chặn tạo mới nếu `AssetItem.status` khác `in_stock`.
- Nếu `AssetItem` đang ở status `retired` sẵn → **chặn tạo** `MaintenanceRecord`, trả `400` (item đã thanh lý thì gửi sửa không còn ý nghĩa).
- **Đóng** `MaintenanceRecord` (set `endDate` + `resolutionNotes` qua `PATCH /api/maintenance-records/[id]`, cùng REST-shape tiền lệ đã chốt cho `Assignment`/`PurchaseOrder` — "chốt 2026-08-02 (b)" ở mục Assignment): body nhận thêm `targetStatus` bắt buộc (vd `in_stock` nếu sửa xong, `retired` nếu sửa không thành công) — **không** tự động mặc định về `in_stock`, vì sửa không phải lúc nào cũng thành công.
- Các field khác ngoài hành động "đóng" record không có update endpoint riêng — set 1 lần lúc tạo, không sửa lại sau, theo đúng pattern hiện có (vd `PurchaseOrder.orderDate` không có update endpoint).
## Module tương lai: Rack/Slot management + IoT (chưa build, chỉ note kiến trúc) ## Module tương lai: Rack/Slot management + IoT (chưa build, chỉ note kiến trúc)
Mục tiêu xa hơn: biết chính xác asset nằm ở ô/slot nào trên tủ rack, và hướng dẫn người lấy đúng ô bằng đèn (pick-to-light). Mục tiêu xa hơn: biết chính xác asset nằm ở ô/slot nào trên tủ rack, và hướng dẫn người lấy đúng ô bằng đèn (pick-to-light).
@@ -141,7 +165,7 @@ QR gán asset vào slot (bước 1, thủ công hàng ngày) **không phải IoT
- Nhân viên nghỉ việc → checklist thu hồi asset + license (offboarding) - Nhân viên nghỉ việc → checklist thu hồi asset + license (offboarding)
- License seat-based dùng chung (vd Adobe CC theo pool chung của phòng ban) - License seat-based dùng chung (vd Adobe CC theo pool chung của phòng ban)
- Tài sản luân chuyển giữa phòng ban / chi nhánh (không phải "delivery" mới, mà là re-assign) - Tài sản luân chuyển giữa phòng ban / chi nhánh (không phải "delivery" mới, mà là re-assign)
- Asset hỏng → gửi RMA/sửa chữa → cần state "in repair" tách khỏi "assigned" - Asset hỏng → gửi RMA/sửa chữa → cần state "in repair" tách khỏi "assigned" — **giải quyết bởi `MaintenanceRecord`, spec chốt 2026-08-04, xem mục "MaintenanceRecord" bên dưới, sẵn sàng cho `pm`**
## Quyết định kiến trúc ## Quyết định kiến trúc
@@ -172,6 +196,7 @@ QR gán asset vào slot (bước 1, thủ công hàng ngày) **không phải IoT
- **`Vendor`, `PurchaseOrder` (gộp `Receipt`), `Assignment`, `AssetCategoryAttributeDef` (2026-08-02): đã có spec đầy đủ (BA), sẵn sàng cho `pm` chia task** — xem các mục "Vendor", "PurchaseOrder (gộp Receipt)", "Assignment" và "Cập nhật 2026-08-02 (3)" trong "Phân loại tài sản" ở trên. Bao gồm cả retrofit `License.vendor` (string) → `License.vendorId` (FK → `Vendor`, không cần backfill vì chưa có `License` thật trong DB) và thêm `purchaseOrderId` (nullable FK) vào `AssetItem`/`License`/`Request`. Thứ tự phụ thuộc gợi ý cho `pm`: `Vendor` → retrofit `License.vendorId` → `PurchaseOrder` (+ `purchaseOrderId` trên 3 entity) → `Assignment`; `AssetCategoryAttributeDef` độc lập, có thể làm song song. - **`Vendor`, `PurchaseOrder` (gộp `Receipt`), `Assignment`, `AssetCategoryAttributeDef` (2026-08-02): đã có spec đầy đủ (BA), sẵn sàng cho `pm` chia task** — xem các mục "Vendor", "PurchaseOrder (gộp Receipt)", "Assignment" và "Cập nhật 2026-08-02 (3)" trong "Phân loại tài sản" ở trên. Bao gồm cả retrofit `License.vendor` (string) → `License.vendorId` (FK → `Vendor`, không cần backfill vì chưa có `License` thật trong DB) và thêm `purchaseOrderId` (nullable FK) vào `AssetItem`/`License`/`Request`. Thứ tự phụ thuộc gợi ý cho `pm`: `Vendor` → retrofit `License.vendorId` → `PurchaseOrder` (+ `purchaseOrderId` trên 3 entity) → `Assignment`; `AssetCategoryAttributeDef` độc lập, có thể làm song song.
- Consumable (2026-08-02): phần phân loại (`AssetCategory`, `ConsumableItem`) đã có spec đầy đủ (BA), sẵn sàng cho `pm` chia task. Luồng "xin + nhận" (`ConsumableRequest` hoặc tương đương) **BỊ CHẶN** — cần stakeholder trả lời các câu hỏi mở: (1) có cần approval không, ai duyệt; (2) ai được tạo request xin (tự xin cho mình hay xin hộ được); (3) có giới hạn số lượng tối đa/lần xin không; (4) hết hàng thì chặn hẳn / cho "nợ" chờ nhập thêm / fulfill một phần; (5) trừ tồn kho lúc tạo request hay lúc fulfill; (6) restock tồn kho qua luồng nào, ai được làm; (7) có cần ghi nhận người phát hàng/ký nhận không. Không tự đoán — xem chi tiết đầy đủ trong spec BA. - Consumable (2026-08-02): phần phân loại (`AssetCategory`, `ConsumableItem`) đã có spec đầy đủ (BA), sẵn sàng cho `pm` chia task. Luồng "xin + nhận" (`ConsumableRequest` hoặc tương đương) **BỊ CHẶN** — cần stakeholder trả lời các câu hỏi mở: (1) có cần approval không, ai duyệt; (2) ai được tạo request xin (tự xin cho mình hay xin hộ được); (3) có giới hạn số lượng tối đa/lần xin không; (4) hết hàng thì chặn hẳn / cho "nợ" chờ nhập thêm / fulfill một phần; (5) trừ tồn kho lúc tạo request hay lúc fulfill; (6) restock tồn kho qua luồng nào, ai được làm; (7) có cần ghi nhận người phát hàng/ký nhận không. Không tự đoán — xem chi tiết đầy đủ trong spec BA.
- Migrate 2 request đang có (`Firefly_License_Request.docx`, `Eric Pelatan.docx`, ở workspace `ubisoft/` ngoài repo này) vào hệ thống mới sau khi có DB - 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
- **`MaintenanceRecord` (spec BA + stakeholder, chốt 2026-08-04): đã có spec đầy đủ, sẵn sàng cho `pm` chia task** — xem mục "MaintenanceRecord" phía trên. Field cơ bản + `type` (`repair`/`warranty`/`preventive`) + `vendorId` (tái dùng `Vendor`, nullable) đã chốt; đồng bộ `AssetItem.status` đã chốt rõ (auto → `repair` lúc mở, chặn nếu có `Assignment` active hoặc đã `retired`, `targetStatus` bắt buộc lúc đóng); không track `cost`.
- (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.
@@ -180,4 +205,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-08-02 (spec `Vendor`, `PurchaseOrder` gộp `Receipt`, `Assignment`, `AssetCategoryAttributeDef` — sẵn sàng cho `pm` chia task) Cập nhật lần cuối: 2026-08-04 (spec `MaintenanceRecord` chốt đủ với stakeholder — sẵn sàng cho `pm`, xem "Việc còn dở")
@@ -0,0 +1,23 @@
-- CreateEnum
CREATE TYPE "MaintenanceType" AS ENUM ('repair', 'warranty', 'preventive');
-- CreateTable
CREATE TABLE "maintenance_records" (
"id" UUID NOT NULL,
"asset_item_id" UUID NOT NULL,
"type" "MaintenanceType" NOT NULL,
"vendor_id" UUID,
"issue_description" TEXT NOT NULL,
"resolution_notes" TEXT,
"start_date" DATE NOT NULL,
"end_date" DATE,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "maintenance_records_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "maintenance_records" ADD CONSTRAINT "maintenance_records_asset_item_id_fkey" FOREIGN KEY ("asset_item_id") REFERENCES "asset_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "maintenance_records" ADD CONSTRAINT "maintenance_records_vendor_id_fkey" FOREIGN KEY ("vendor_id") REFERENCES "vendors"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
-- DropForeignKey
ALTER TABLE "assignments" DROP CONSTRAINT "assignments_asset_item_id_fkey";
-- DropForeignKey
ALTER TABLE "assignments" DROP CONSTRAINT "assignments_license_id_fkey";
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_asset_item_id_fkey" FOREIGN KEY ("asset_item_id") REFERENCES "asset_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_license_id_fkey" FOREIGN KEY ("license_id") REFERENCES "licenses"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+58 -2
View File
@@ -125,6 +125,11 @@ model AssetItem {
// multiple Assignment rows over its lifetime (historical + at most one // multiple Assignment rows over its lifetime (historical + at most one
// active, see Assignment model's comment), not built until this task. // active, see Assignment model's comment), not built until this task.
assignments Assignment[] assignments Assignment[]
// Back-relation for MaintenanceRecord.assetItemId — an AssetItem can have
// multiple MaintenanceRecord rows over its lifetime (historical + at most
// one open, see MaintenanceRecord model's comment), not built until this
// task.
maintenanceRecords MaintenanceRecord[]
@@map("asset_items") @@map("asset_items")
} }
@@ -194,6 +199,10 @@ model Vendor {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
licenses License[] licenses License[]
purchaseOrders PurchaseOrder[] purchaseOrders PurchaseOrder[]
// Back-relation for MaintenanceRecord.vendorId — a Vendor can perform
// multiple repairs/warranty jobs over time, same reuse rationale as
// PurchaseOrder.vendorId (see MaintenanceRecord model's comment).
maintenanceRecords MaintenanceRecord[]
@@map("vendors") @@map("vendors")
} }
@@ -389,9 +398,9 @@ model Assignment {
employeeId String @map("employee_id") @db.Uuid employeeId String @map("employee_id") @db.Uuid
employee Employee @relation(fields: [employeeId], references: [id]) employee Employee @relation(fields: [employeeId], references: [id])
assetItemId String? @map("asset_item_id") @db.Uuid assetItemId String? @map("asset_item_id") @db.Uuid
assetItem AssetItem? @relation(fields: [assetItemId], references: [id]) assetItem AssetItem? @relation(fields: [assetItemId], references: [id], onDelete: Restrict)
licenseId String? @map("license_id") @db.Uuid licenseId String? @map("license_id") @db.Uuid
license License? @relation(fields: [licenseId], references: [id]) license License? @relation(fields: [licenseId], references: [id], onDelete: Restrict)
assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(6) assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(6)
returnedAt DateTime? @map("returned_at") @db.Timestamptz(6) returnedAt DateTime? @map("returned_at") @db.Timestamptz(6)
signedAt DateTime? @map("signed_at") @db.Timestamptz(6) signedAt DateTime? @map("signed_at") @db.Timestamptz(6)
@@ -417,3 +426,50 @@ model User {
@@map("users") @@map("users")
} }
// Fixed, closed set of 3 values tied to the domain (see docs/
// asset-license-management.md's "## MaintenanceRecord (spec BA + stakeholder,
// chốt 2026-08-04)" section) — native Postgres enum, same rationale as
// RequestType/AssetItemStatus/LicenseType/AssetCategoryValueGroup/
// AssetAttributeDataType above.
enum MaintenanceType {
repair
warranty
preventive
}
// Records a repair/warranty/preventive-maintenance job for a specific
// AssetItem — see docs/asset-license-management.md's "## MaintenanceRecord
// (spec BA + stakeholder, chốt 2026-08-04)" section. Only applies to
// AssetItem (hardware) — License has no physical-repair concept, so unlike
// Assignment there's no XOR here: assetItemId is the single, always-required
// target. `vendorId` reuses the existing Vendor entity (same "external
// counterparty" concept as PurchaseOrder.vendorId) and is nullable — null
// means an internal repair with no outside vendor involved. No `cost` field,
// per the same precedent as PurchaseOrder (stakeholder explicitly said no
// money tracking there). No separate `status` field either — "open" vs
// "done" is derived from `endDate` (null = still being worked on, set = done),
// same precedent as PurchaseOrder.receivedAt not duplicating state. `endDate
// >= startDate` and "at most one OPEN (endDate IS NULL) MaintenanceRecord per
// AssetItem" are both business invariants enforced at the application layer
// (src/lib/maintenance-records.ts, not built in this task) — NOT DB
// constraints, per the same convention as Assignment's XOR / "at most one
// active" invariant (see Assignment model's comment above for the mirrored
// trade-off). No FK to User for "who logged this" — no existing precedent in
// this codebase ties a business entity to User (User is login-only), see
// doc for this explicit call.
model MaintenanceRecord {
id String @id @default(uuid()) @db.Uuid
assetItemId String @map("asset_item_id") @db.Uuid
assetItem AssetItem @relation(fields: [assetItemId], references: [id])
type MaintenanceType
vendorId String? @map("vendor_id") @db.Uuid
vendor Vendor? @relation(fields: [vendorId], references: [id])
issueDescription String @map("issue_description")
resolutionNotes String? @map("resolution_notes")
startDate DateTime @map("start_date") @db.Date
endDate DateTime? @map("end_date") @db.Date
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("maintenance_records")
}
+79 -3
View File
@@ -1,8 +1,12 @@
// Standalone CLI entry point for creating an internal auth account by hand: // Standalone CLI entry point for creating an internal auth account by hand:
// `npx tsx scripts/create-user.ts --email [email protected] --name "Foo Bar" --password "..."` // `npx tsx scripts/create-user.ts --email [email protected] --name "Foo Bar"`
// (you'll be prompted for the password interactively — see readPassword()
// below; it is deliberately never accepted via a CLI flag, to avoid leaking
// it through shell history / `ps` / process listings).
// There is deliberately no self-registration UI or public API route for // There is deliberately no self-registration UI or public API route for
// this — see src/lib/users.ts / prisma/schema.prisma's User model comment // this — see src/lib/users.ts / prisma/schema.prisma's User model comment
// for why (a handful of trusted IT staff accounts, not company-wide). // for why (a handful of trusted IT staff accounts, not company-wide).
import readline from "readline";
import { createUser, validateCreateUserInput } from "../src/lib/users"; import { createUser, validateCreateUserInput } from "../src/lib/users";
import { prisma } from "../src/lib/prisma"; import { prisma } from "../src/lib/prisma";
@@ -24,19 +28,91 @@ function parseArgs(argv: string[]): Record<string, string> {
return args; return args;
} }
/**
* Prompts for a password on stdin without echoing the typed characters back
* to the terminal (each keystroke is masked with `*`). Only Node built-ins
* (`readline` + `process.stdin` raw mode) — no extra dependency.
*
* Falls back to a plain (unmasked) `readline` question when stdin isn't a
* TTY (e.g. input piped in for scripting/testing) since raw mode requires a
* real terminal.
*/
function readPassword(promptText: string): Promise<string> {
const stdin = process.stdin;
const stdout = process.stdout;
if (!stdin.isTTY) {
return new Promise((resolve) => {
const rl = readline.createInterface({ input: stdin, output: stdout, terminal: false });
rl.question(promptText, (answer) => {
rl.close();
resolve(answer);
});
});
}
return new Promise((resolve, reject) => {
stdout.write(promptText);
stdin.resume();
stdin.setRawMode(true);
stdin.setEncoding("utf8");
let password = "";
const cleanup = () => {
stdin.setRawMode(false);
stdin.pause();
stdin.removeListener("data", onData);
};
const onData = (chunk: string) => {
switch (chunk) {
case "\n":
case "\r":
case "\u0004": // Ctrl-D
cleanup();
stdout.write("\n");
resolve(password);
break;
case "\u0003": // Ctrl-C
cleanup();
stdout.write("\n");
reject(new Error("Aborted"));
break;
case "\u007f": // Backspace (most terminals)
case "\b":
if (password.length > 0) {
password = password.slice(0, -1);
stdout.write("\b \b");
}
break;
default:
password += chunk;
stdout.write("*");
break;
}
};
stdin.on("data", onData);
});
}
async function main(): Promise<void> { async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2)); const args = parseArgs(process.argv.slice(2));
const password = await readPassword("Password: ");
const validation = validateCreateUserInput({ const validation = validateCreateUserInput({
email: args.email, email: args.email,
name: args.name, name: args.name,
password: args.password, password,
}); });
if (!validation.ok) { if (!validation.ok) {
console.error(`Error: ${validation.error}`); console.error(`Error: ${validation.error}`);
console.error( console.error(
'Usage: npx tsx scripts/create-user.ts --email [email protected] --name "Foo Bar" --password "..."' 'Usage: npx tsx scripts/create-user.ts --email [email protected] --name "Foo Bar" (password prompted interactively)'
); );
process.exitCode = 1; process.exitCode = 1;
return; return;
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import {
closeMaintenanceRecord,
validateCloseMaintenanceRecordInput,
} from "@/lib/maintenance-records";
// Mirrors src/app/api/assignments/[id]/route.ts's pattern — PATCH is the
// only update action for MaintenanceRecord ("close"), no dispatch-on-body
// needed here since there is no second action shape (unlike Assignment's
// return-vs-transfer). Next.js 14 App Router route handler params are a
// plain synchronous object (not a Promise) — see the Assignment route's
// same comment for why.
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Validate the [id] path segment's format up front — an invalid UUID
// passed straight to Prisma's `findUnique({ where: { id } })` (a `@db.Uuid`
// column) throws a raw PrismaClientKnownRequestError that
// closeMaintenanceRecord()'s try/catch doesn't specifically handle, which
// would otherwise leak as an uncaught 500 instead of a clean 400.
if (!z.string().uuid().safeParse(params.id).success) {
return NextResponse.json({ error: "id must be a valid UUID string" }, { status: 400 });
}
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 { endDate, resolutionNotes, targetStatus } = body as Record<string, unknown>;
const validation = validateCloseMaintenanceRecordInput({
endDate,
resolutionNotes,
targetStatus,
});
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const closed = await closeMaintenanceRecord(params.id, validation.value);
if (!closed.ok) {
return NextResponse.json({ error: closed.error }, { status: closed.status });
}
return NextResponse.json(closed.value, { status: 200 });
}
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {
createMaintenanceRecord,
listMaintenanceRecords,
validateCreateMaintenanceRecordInput,
} from "@/lib/maintenance-records";
// Mirrors src/app/api/assignments/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 { assetItemId, type, vendorId, issueDescription, startDate } =
body as Record<string, unknown>;
const validation = validateCreateMaintenanceRecordInput({
assetItemId,
type,
vendorId,
issueDescription,
startDate,
});
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createMaintenanceRecord(validation.value);
if (!created.ok) {
return NextResponse.json({ error: created.error }, { status: 400 });
}
return NextResponse.json(created.value, { status: 201 });
}
export async function GET(): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const records = await listMaintenanceRecords();
return NextResponse.json(records, { status: 200 });
}
+105
View File
@@ -88,6 +88,14 @@ export default function AssignmentsPage() {
const [returnErrors, setReturnErrors] = useState<Record<string, string>>({}); const [returnErrors, setReturnErrors] = useState<Record<string, string>>({});
const [returningId, setReturningId] = useState<string | null>(null); const [returningId, setReturningId] = useState<string | null>(null);
// Per-row transfer mini-form state, keyed by assignment id — same shape as
// maintenance-records/page.tsx's close mini-form state (separate maps
// rather than one nested-object map, so each field stays a plain string
// setter).
const [transferEmployeeId, setTransferEmployeeId] = useState<Record<string, string>>({});
const [transferErrors, setTransferErrors] = useState<Record<string, string>>({});
const [transferringId, setTransferringId] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) { async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setSubmitError(null); setSubmitError(null);
@@ -183,6 +191,61 @@ export default function AssignmentsPage() {
} }
} }
async function handleTransfer(id: string) {
setTransferringId(id);
setTransferErrors((previous) => {
const next = { ...previous };
delete next[id];
return next;
});
const employeeId = transferEmployeeId[id] ?? "";
try {
// PATCH /api/assignments/[id]'s body is `{employeeId}` for a transfer
// — see src/app/api/assignments/[id]/route.ts's dispatch (a body
// carrying employeeId is a transfer, everything else is a return).
// employeeId is sent even if unselected (empty string), which the
// server rejects with a clean 400 message surfaced below, same as any
// other validation failure.
const response = await fetch(`/api/assignments/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ employeeId }),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setTransferErrors((previous) => ({ ...previous, [id]: message }));
return;
}
setTransferEmployeeId((previous) => {
const next = { ...previous };
delete next[id];
return next;
});
refetch();
} catch {
setTransferErrors((previous) => ({ ...previous, [id]: "Request failed" }));
} finally {
setTransferringId(null);
}
}
return ( return (
<div className="flex flex-col gap-8 p-6"> <div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Assignments</h1> <h1 className="text-xl font-semibold">Assignments</h1>
@@ -220,6 +283,7 @@ export default function AssignmentsPage() {
<TableCell>{assignment.created_at}</TableCell> <TableCell>{assignment.created_at}</TableCell>
<TableCell> <TableCell>
{assignment.returned_at === null ? ( {assignment.returned_at === null ? (
<div className="flex w-56 flex-col gap-2">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Button <Button
type="button" type="button"
@@ -236,6 +300,47 @@ export default function AssignmentsPage() {
</p> </p>
) : null} ) : null}
</div> </div>
<div className="flex flex-col gap-1">
<Select
value={transferEmployeeId[assignment.id] ?? ""}
onValueChange={(value) =>
setTransferEmployeeId((previous) => ({
...previous,
[assignment.id]: value,
}))
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Transfer to..." />
</SelectTrigger>
<SelectContent>
{employees.map((employee) => (
<SelectItem key={employee.id} value={employee.id}>
{employee.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
variant="outline"
disabled={
transferringId === assignment.id ||
!transferEmployeeId[assignment.id]
}
onClick={() => handleTransfer(assignment.id)}
>
{transferringId === assignment.id ? "Transferring..." : "Transfer"}
</Button>
{transferErrors[assignment.id] ? (
<p className="text-xs text-destructive">
{transferErrors[assignment.id]}
</p>
) : null}
</div>
</div>
) : ( ) : (
"-" "-"
)} )}
+415
View File
@@ -0,0 +1,415 @@
"use client";
import { useState, type FormEvent } from "react";
import { useApiList } from "@/hooks/use-api-list";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Shape returned by GET /api/maintenance-records — snake_case per this
// repo's API-boundary convention (see src/lib/maintenance-records.ts's
// toRecord()). An "open" record is one where end_date is null.
interface MaintenanceRecordRecord {
id: string;
asset_item_id: string;
type: "repair" | "warranty" | "preventive";
vendor_id: string | null;
issue_description: string;
resolution_notes: string | null;
start_date: string;
end_date: string | null;
created_at: string;
}
// Shape returned by GET /api/assets — only the field used to populate the
// assetItemId <Select> here (see src/app/assets/page.tsx's AssetItemRecord
// for the full shape).
interface AssetItemRecord {
id: string;
serial_number: string;
}
// Shape returned by GET /api/vendors — only the field used to populate the
// vendorId <Select> here (see src/app/vendors/page.tsx's VendorRecord for
// the full shape).
interface VendorRecord {
id: string;
name: string;
}
// The 4 AssetItemStatus enum values, reused verbatim as the targetStatus
// <Select>'s option list — mirrors src/lib/maintenance-records.ts's own
// assetItemStatusValues constant (not re-derived from an API call, this is
// a closed set already known client-side, same precedent as licenses
// page's licenseType/perpetual|subscription <Select>).
const ASSET_ITEM_STATUS_VALUES = ["in_stock", "assigned", "repair", "retired"] as const;
// Radix <Select.Item> can't take an empty-string value, so "no vendor" is
// represented by this sentinel in the form state and translated to "omit
// the key" on submit — same pattern as licenses page's
// NO_PURCHASE_ORDER_VALUE / employees page's NO_MANAGER_VALUE.
const NO_VENDOR_VALUE = "none";
function todayDateString(): string {
return new Date().toISOString().slice(0, 10);
}
export default function MaintenanceRecordsPage() {
const {
data: maintenanceRecords,
loading,
error,
refetch,
} = useApiList<MaintenanceRecordRecord>("/api/maintenance-records");
const { data: assetItems } = useApiList<AssetItemRecord>("/api/assets");
const { data: vendors } = useApiList<VendorRecord>("/api/vendors");
const [assetItemId, setAssetItemId] = useState("");
const [type, setType] = useState("");
const [vendorId, setVendorId] = useState(NO_VENDOR_VALUE);
const [issueDescription, setIssueDescription] = useState("");
const [startDate, setStartDate] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// Per-row close mini-form state, keyed by maintenance record id — kept as
// separate maps (rather than one nested-object map) to keep each field's
// setter a plain string setter, same shape as returnErrors/returningId on
// assignments/page.tsx which this page mirrors.
const [closeEndDate, setCloseEndDate] = useState<Record<string, string>>({});
const [closeResolutionNotes, setCloseResolutionNotes] = useState<Record<string, string>>({});
const [closeTargetStatus, setCloseTargetStatus] = useState<Record<string, string>>({});
const [closeErrors, setCloseErrors] = useState<Record<string, string>>({});
const [closingId, setClosingId] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
setSubmitting(true);
try {
const response = await fetch("/api/maintenance-records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
assetItemId,
type,
issueDescription,
startDate,
// Omit the key entirely when left unselected, rather than sending
// null — matches licenses page's purchaseOrderId pattern.
...(vendorId !== NO_VENDOR_VALUE ? { vendorId } : {}),
}),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setSubmitError(message);
return;
}
setAssetItemId("");
setType("");
setVendorId(NO_VENDOR_VALUE);
setIssueDescription("");
setStartDate("");
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
async function handleClose(id: string) {
setClosingId(id);
setCloseErrors((previous) => {
const next = { ...previous };
delete next[id];
return next;
});
const endDate = closeEndDate[id] ?? todayDateString();
const resolutionNotes = closeResolutionNotes[id] ?? "";
const targetStatus = closeTargetStatus[id] ?? "";
try {
// PATCH /api/maintenance-records/[id]'s body is
// `{endDate, resolutionNotes?, targetStatus}` — see src/lib/
// maintenance-records.ts's validateCloseMaintenanceRecordInput().
// Unlike Assignment's returnedAt, targetStatus is REQUIRED and never
// defaulted server-side, so it's always sent here (an empty string
// if unselected, which the server rejects with a clean 400 message
// surfaced below, same as any other validation failure).
const response = await fetch(`/api/maintenance-records/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
endDate,
targetStatus,
...(resolutionNotes.trim() !== "" ? { resolutionNotes } : {}),
}),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setCloseErrors((previous) => ({ ...previous, [id]: message }));
return;
}
setCloseEndDate((previous) => {
const next = { ...previous };
delete next[id];
return next;
});
setCloseResolutionNotes((previous) => {
const next = { ...previous };
delete next[id];
return next;
});
setCloseTargetStatus((previous) => {
const next = { ...previous };
delete next[id];
return next;
});
refetch();
} catch {
setCloseErrors((previous) => ({ ...previous, [id]: "Request failed" }));
} finally {
setClosingId(null);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Maintenance Records</h1>
<section className="flex flex-col gap-3">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Asset Item ID</TableHead>
<TableHead>Type</TableHead>
<TableHead>Vendor ID</TableHead>
<TableHead>Issue Description</TableHead>
<TableHead>Resolution Notes</TableHead>
<TableHead>Start Date</TableHead>
<TableHead>End Date</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{maintenanceRecords.map((record) => (
<TableRow key={record.id}>
<TableCell>{record.id}</TableCell>
<TableCell>{record.asset_item_id}</TableCell>
<TableCell>{record.type}</TableCell>
<TableCell>{record.vendor_id ?? "-"}</TableCell>
<TableCell>{record.issue_description}</TableCell>
<TableCell>{record.resolution_notes ?? "-"}</TableCell>
<TableCell>{record.start_date}</TableCell>
<TableCell>{record.end_date ?? "-"}</TableCell>
<TableCell>{record.created_at}</TableCell>
<TableCell>
{record.end_date === null ? (
<div className="flex w-56 flex-col gap-1">
<Input
type="date"
aria-label="End date"
value={closeEndDate[record.id] ?? todayDateString()}
onChange={(event) =>
setCloseEndDate((previous) => ({
...previous,
[record.id]: event.target.value,
}))
}
/>
<Input
type="text"
placeholder="Resolution notes (optional)"
value={closeResolutionNotes[record.id] ?? ""}
onChange={(event) =>
setCloseResolutionNotes((previous) => ({
...previous,
[record.id]: event.target.value,
}))
}
/>
<Select
value={closeTargetStatus[record.id] ?? ""}
onValueChange={(value) =>
setCloseTargetStatus((previous) => ({
...previous,
[record.id]: value,
}))
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Target status" />
</SelectTrigger>
<SelectContent>
{ASSET_ITEM_STATUS_VALUES.map((status) => (
<SelectItem key={status} value={status}>
{status}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
variant="outline"
disabled={closingId === record.id}
onClick={() => handleClose(record.id)}
>
{closingId === record.id ? "Closing..." : "Close"}
</Button>
{closeErrors[record.id] ? (
<p className="text-xs text-destructive">
{closeErrors[record.id]}
</p>
) : null}
</div>
) : (
"-"
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Maintenance Record</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="assetItemId">Asset Item</Label>
<Select value={assetItemId} onValueChange={setAssetItemId} required>
<SelectTrigger id="assetItemId" className="w-full">
<SelectValue placeholder="Select an asset item" />
</SelectTrigger>
<SelectContent>
{assetItems.map((assetItem) => (
<SelectItem key={assetItem.id} value={assetItem.id}>
{assetItem.serial_number}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="type">Type</Label>
<Select value={type} onValueChange={setType} required>
<SelectTrigger id="type" className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="repair">repair</SelectItem>
<SelectItem value="warranty">warranty</SelectItem>
<SelectItem value="preventive">preventive</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="vendorId">Vendor (optional)</Label>
<Select value={vendorId} onValueChange={setVendorId}>
<SelectTrigger id="vendorId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_VENDOR_VALUE}>None</SelectItem>
{vendors.map((vendor) => (
<SelectItem key={vendor.id} value={vendor.id}>
{vendor.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="issueDescription">Issue Description</Label>
<Textarea
id="issueDescription"
value={issueDescription}
onChange={(event) => setIssueDescription(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="startDate">Start Date</Label>
<Input
id="startDate"
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
required
/>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Maintenance Record"}
</Button>
</form>
</section>
</div>
);
}
+1
View File
@@ -21,6 +21,7 @@ const ENTITY_LINKS = [
{ href: "/purchase-orders", label: "Purchase Orders" }, { href: "/purchase-orders", label: "Purchase Orders" },
{ href: "/requests", label: "Requests" }, { href: "/requests", label: "Requests" },
{ href: "/assignments", label: "Assignments" }, { href: "/assignments", label: "Assignments" },
{ href: "/maintenance-records", label: "Maintenance Records" },
] as const; ] as const;
export function Nav() { export function Nav() {
+438
View File
@@ -0,0 +1,438 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
import type { AssetItemStatus } from "./assets";
export type MaintenanceType = "repair" | "warranty" | "preventive";
export interface MaintenanceRecordRecord {
id: string;
asset_item_id: string;
type: MaintenanceType;
vendor_id: string | null;
issue_description: string;
resolution_notes: string | null;
start_date: string;
end_date: string | null;
created_at: string;
}
// resolutionNotes/endDate are deliberately NOT accepted from the client at
// creation time — same precedent as Assignment.returnedAt not being
// accepted at creation (see src/lib/assignments.ts's CreateAssignmentInput
// doc comment): a MaintenanceRecord always opens with endDate/
// resolutionNotes null, only closeMaintenanceRecord() sets them later.
export interface CreateMaintenanceRecordInput {
assetItemId: string;
type: MaintenanceType;
vendorId?: string | null;
issueDescription: string;
startDate: Date;
}
export type ValidationResult =
| { ok: true; value: CreateMaintenanceRecordInput }
| { 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/assignments.ts's dateString helper — accepts any string
// Date.parse() can handle 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 createMaintenanceRecordSchema = z.object({
// FK to AssetItem — only a UUID *format* check here. Unlike a plain
// supply-side FK (e.g. AssetItem.purchaseOrderId), existence is checked
// explicitly inside the transaction below (not deferred entirely to
// Prisma's insert-time FK constraint), because createMaintenanceRecord()
// needs to read the referenced row anyway to enforce the status/active-
// assignment/already-open business rules. Mirrors
// src/lib/assignments.ts's assetItemId handling.
assetItemId: z
.string({ required_error: "assetItemId is required and must be a valid UUID string" })
.uuid("assetItemId is required and must be a valid UUID string"),
// Mirrors src/lib/requests.ts's type z.enum() pattern.
type: z.enum(["repair", "warranty", "preventive"], {
errorMap: () => ({
message: "type is required and must be one of 'repair', 'warranty', 'preventive'",
}),
}),
// FK to Vendor — nullable/optional, null means an internal repair with no
// outside vendor involved (see schema.prisma's MaintenanceRecord comment).
// Only a UUID *format* check here, whether it references a real vendor is
// left to Prisma's own FK constraint at insert time (see
// createMaintenanceRecord()'s P2003 handling below). Mirrors
// src/lib/licenses.ts's vendorId handling (except nullable here).
vendorId: z
.string({ invalid_type_error: "vendorId must be a valid UUID string" })
.uuid("vendorId must be a valid UUID string")
.optional()
.nullable(),
issueDescription: nonEmptyString(
"issueDescription is required and must be a non-empty string"
),
startDate: dateString("startDate is required and must be a valid date string"),
});
/**
* Validates raw (untyped) maintenance-record-creation input. assetItemId
* must be a valid UUID string, type must be one of the three enum values,
* vendorId is optional/nullable but must be a valid UUID string if present,
* issueDescription must be a non-empty string, startDate must be a valid
* date string. resolutionNotes/endDate are not accepted here — see
* CreateMaintenanceRecordInput's doc comment for why.
*/
export function validateCreateMaintenanceRecordInput(input: {
assetItemId?: unknown;
type?: unknown;
vendorId?: unknown;
issueDescription?: unknown;
startDate?: unknown;
}): ValidationResult {
const result = createMaintenanceRecordSchema.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 };
}
// Closed set of the 2 AssetItemStatus values a maintenance-record close is
// allowed to land on — deliberately NOT the full 4-value AssetItemStatus
// enum (see closeMaintenanceRecordSchema's targetStatus comment below for
// why "assigned"/"repair" are excluded).
const closeableTargetStatusValues = ["in_stock", "retired"] as const;
const closeMaintenanceRecordSchema = z.object({
endDate: dateString("endDate is required and must be a valid date string"),
// Nullable/optional — a closed record doesn't strictly require notes (e.g.
// a warranty swap with nothing more to say than "resolved"). Mirrors the
// optional().nullable() shape used for other nullable-in-DB text/FK
// fields throughout this codebase (e.g. AssetItem.purchaseOrderId).
resolutionNotes: z
.string({ invalid_type_error: "resolutionNotes must be a string" })
.optional()
.nullable(),
// Required — deliberately NOT defaulted to "in_stock", per docs/
// asset-license-management.md's "MaintenanceRecord" section: "không tự
// động mặc định về in_stock, vì sửa không phải lúc nào cũng thành công."
// Restricted to "in_stock" | "retired" only (NOT the full 4-value
// AssetItemStatus enum) — those are the only two outcomes a repair can
// actually resolve to (fixed → back to stock, unfixable → write off).
// "assigned" is deliberately rejected: setting AssetItem.status to
// "assigned" here would NOT create a corresponding Assignment row, so
// Assignment (the source of truth for "who currently holds what") and
// AssetItem.status would go out of sync with no way back — createAssignment()
// only accepts a "in_stock" AssetItem, and there is no standalone
// AssetItem-status-edit endpoint to undo it by hand. "repair" is
// rejected too: it would leave the record's own AssetItem "in repair"
// despite this exact call being the one that closes out its maintenance
// record — a closed record whose item is still marked "repair" is a
// contradiction, and it would also make the item wrongly ineligible for
// a new maintenance record's own "no other open record" precondition to
// ever apply cleanly (a new one could still be opened as a *different*
// record row while the old one sits closed but the item is still
// "repair").
targetStatus: z.enum(closeableTargetStatusValues, {
errorMap: () => ({
message: "targetStatus is required and must be one of 'in_stock', 'retired'",
}),
}),
});
export type CloseMaintenanceRecordInput = {
endDate: Date;
resolutionNotes?: string | null;
targetStatus: AssetItemStatus;
};
export type CloseValidationResult =
| { ok: true; value: CloseMaintenanceRecordInput }
| { ok: false; error: string };
/**
* Validates raw (untyped) maintenance-record-close input. endDate must be a
* valid date string, resolutionNotes is optional/nullable but must be a
* string if present, targetStatus is required and must be a valid
* AssetItemStatus value. endDate >= startDate cannot be checked here (it
* needs the already-saved record's startDate from the DB) — that's enforced
* inside closeMaintenanceRecord() itself.
*/
export function validateCloseMaintenanceRecordInput(input: {
endDate?: unknown;
resolutionNotes?: unknown;
targetStatus?: unknown;
}): CloseValidationResult {
const result = closeMaintenanceRecordSchema.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;
assetItemId: string;
type: MaintenanceType;
vendorId: string | null;
issueDescription: string;
resolutionNotes: string | null;
startDate: Date;
endDate: Date | null;
createdAt: Date;
}): MaintenanceRecordRecord {
return {
id: row.id,
asset_item_id: row.assetItemId,
type: row.type,
vendor_id: row.vendorId,
issue_description: row.issueDescription,
resolution_notes: row.resolutionNotes,
start_date: row.startDate.toISOString(),
end_date: row.endDate ? row.endDate.toISOString() : null,
created_at: row.createdAt.toISOString(),
};
}
// Internal error type used to carry a clean, already-user-facing message out
// of the `prisma.$transaction()` callback below, distinguishing
// application-level business-rule rejections (400) from unexpected/infra
// errors (rethrown as-is). Not exported — callers only ever see the
// {ok,error} result shape. Mirrors src/lib/assignments.ts's
// AssignmentBusinessError.
class MaintenanceRecordBusinessError extends Error {
constructor(message: string) {
super(message);
this.name = "MaintenanceRecordBusinessError";
}
}
class MaintenanceRecordNotFoundError extends Error {
constructor() {
super("MaintenanceRecord not found");
this.name = "MaintenanceRecordNotFoundError";
}
}
export type CreateMaintenanceRecordResult =
| { ok: true; value: MaintenanceRecordRecord }
| { ok: false; error: string };
/**
* Opens a MaintenanceRecord for an AssetItem, enforcing every business rule
* from docs/asset-license-management.md's "## MaintenanceRecord (spec BA +
* stakeholder, chốt 2026-08-04)" section inside a single
* `prisma.$transaction()`:
*
* - Rejects if the AssetItem doesn't exist.
* - Rejects if the AssetItem is already "retired" (thanh lý thì gửi sửa
* không còn ý nghĩa).
* - Rejects if the AssetItem has an ACTIVE Assignment (returnedAt IS NULL)
* — must be returned first, mirrors createAssignment()'s own
* status precondition check.
* - Rejects if the AssetItem already has another OPEN MaintenanceRecord
* (endDate IS NULL) — at most one open record per item at a time (a
* physical item can't be in two repair places at once).
* - Atomically flips AssetItem.status to "repair".
*
* Race-condition note: mirrors src/lib/assignments.ts's createAssignment()
* pattern — the AssetItem.status flip uses a WHERE-guarded `updateMany`
* (`where: { id: assetItemId, status: "in_stock" }`) rather than a
* plain read-then-write. Pinning the exact required prior value (not
* excluding "retired") matters: an excluding guard would still match an
* item another concurrent transaction just flipped to "assigned" or
* "repair", letting two concurrent opens (or an open racing an Assignment
* create) both win. That `updateMany`'s `count` is the actual
* race-closing gate: two concurrent opens racing on the same AssetItem
* serialize on Postgres's row lock for that UPDATE, the first to commit
* wins, and the second's WHERE clause no longer matches (status is no
* longer what it read), so its `updateMany` returns `count: 0`, treated
* here as "lost the race". The `findUnique` above it is NOT itself the
* race-closing check (same TOCTOU caveat as createAssignment()'s doc
* comment) — it only produces a specific, friendly error message
* (not-found vs. retired vs. active-assignment vs. already-open) for the
* common (non-racing) case.
*/
export async function createMaintenanceRecord(
input: CreateMaintenanceRecordInput
): Promise<CreateMaintenanceRecordResult> {
try {
const created = await prisma.$transaction(async (tx) => {
const assetItem = await tx.assetItem.findUnique({ where: { id: input.assetItemId } });
if (!assetItem) {
throw new MaintenanceRecordBusinessError(
"assetItemId does not reference an existing asset item"
);
}
if (assetItem.status === "retired") {
throw new MaintenanceRecordBusinessError(
"assetItemId refers to an AssetItem that is retired and cannot be sent for maintenance"
);
}
const activeAssignment = await tx.assignment.findFirst({
where: { assetItemId: input.assetItemId, returnedAt: null },
});
if (activeAssignment) {
throw new MaintenanceRecordBusinessError(
"assetItemId has an active assignment (returnedAt IS NULL) — return it before creating a maintenance record"
);
}
const openRecord = await tx.maintenanceRecord.findFirst({
where: { assetItemId: input.assetItemId, endDate: null },
});
if (openRecord) {
throw new MaintenanceRecordBusinessError(
"assetItemId already has an open maintenance record (endDate IS NULL)"
);
}
// Atomic race-closing gate — see this function's doc comment. Pins the
// exact prior value ("in_stock") rather than excluding a value
// ("not retired"), mirroring createAssignment()'s own status guard
// (src/lib/assignments.ts) — the excluding form would still match an
// AssetItem another concurrent transaction just flipped to "assigned"
// or "repair" (neither equals "retired"), letting two opens (or an
// open racing an Assignment create) both win.
const statusUpdate = await tx.assetItem.updateMany({
where: { id: input.assetItemId, status: "in_stock" },
data: { status: "repair" },
});
if (statusUpdate.count === 0) {
throw new MaintenanceRecordBusinessError(
"assetItemId refers to an AssetItem that is not in_stock (lost a race to a concurrent status change)"
);
}
return tx.maintenanceRecord.create({
data: {
assetItemId: input.assetItemId,
type: input.type,
vendorId: input.vendorId ?? null,
issueDescription: input.issueDescription,
startDate: input.startDate,
endDate: null,
resolutionNotes: null,
},
});
});
return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof MaintenanceRecordBusinessError) {
return { ok: false, error: error.message };
}
// Only vendorId can still fail with a raw FK violation here —
// assetItemId existence is already fully resolved above (found-or-
// rejected before the transaction ever reaches
// tx.maintenanceRecord.create()).
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
return { ok: false, error: "vendorId does not reference an existing vendor" };
}
throw error;
}
}
export async function listMaintenanceRecords(): Promise<MaintenanceRecordRecord[]> {
const rows = await prisma.maintenanceRecord.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
export type CloseMaintenanceRecordResult =
| { ok: true; value: MaintenanceRecordRecord }
| { ok: false; status: 404 | 400; error: string };
/**
* Closes a MaintenanceRecord (sets endDate + resolutionNotes) and applies
* the caller-chosen `targetStatus` to its AssetItem, inside a single
* `prisma.$transaction()` — docs/asset-license-management.md's
* "MaintenanceRecord" section, "Đóng MaintenanceRecord" bullet.
* targetStatus is never defaulted (e.g. to "in_stock") — repair doesn't
* always succeed, the caller must say explicitly what state the item ends
* up in (e.g. "in_stock" if fixed, "retired" if the repair failed).
*
* Race-condition note: mirrors src/lib/assignments.ts's
* returnAssignment()/transferAssignment() pattern exactly — double-close is
* closed by a single atomic, WHERE-guarded `updateMany`
* (`where: { id, endDate: null }`) that both checks AND writes endDate/
* resolutionNotes in one statement, rather than a separate guard check
* followed by a plain `update()`. Two concurrent close calls against the
* same MaintenanceRecord race on that row's UPDATE lock; only the first to
* commit gets `count: 1`, the second gets `count: 0` and is rejected as
* "already closed". The initial `findUnique` (used to read startDate for
* the endDate>=startDate check, and to distinguish "not found" from
* "already closed" in the common case) is NOT itself the race-closing check
* — same caveat as the Assignment lib's doc comments. The endDate>=startDate
* business-rule check is deliberately done BEFORE the atomic updateMany
* (using the record read by the preceding findUnique) since it only needs
* the record's already-immutable startDate, not anything the race could
* change. The subsequent AssetItem.status write does not need its own
* atomic guard — same rationale as returnAssignment()'s plain
* `tx.assetItem.update()` for its own status flip: targetStatus is applied
* unconditionally, there's no upper/lower bound to race on the way there
* was for License.seatsUsed at create time.
*/
export async function closeMaintenanceRecord(
id: string,
input: CloseMaintenanceRecordInput
): Promise<CloseMaintenanceRecordResult> {
try {
const updated = await prisma.$transaction(async (tx) => {
const record = await tx.maintenanceRecord.findUnique({ where: { id } });
if (!record) {
throw new MaintenanceRecordNotFoundError();
}
if (input.endDate.getTime() < record.startDate.getTime()) {
throw new MaintenanceRecordBusinessError("endDate must be on or after startDate");
}
// Atomic race-closing gate — see this function's doc comment. Also
// doubles as the "already closed" rejection in the non-racing case,
// since `record.endDate !== null` implies this WHERE clause won't
// match either.
const claim = await tx.maintenanceRecord.updateMany({
where: { id, endDate: null },
data: {
endDate: input.endDate,
resolutionNotes: input.resolutionNotes ?? null,
},
});
if (claim.count === 0) {
throw new MaintenanceRecordBusinessError("MaintenanceRecord has already been closed");
}
await tx.assetItem.update({
where: { id: record.assetItemId },
data: { status: input.targetStatus },
});
return tx.maintenanceRecord.findUniqueOrThrow({ where: { id } });
});
return { ok: true, value: toRecord(updated) };
} catch (error) {
if (error instanceof MaintenanceRecordNotFoundError) {
return { ok: false, status: 404, error: "MaintenanceRecord not found" };
}
if (error instanceof MaintenanceRecordBusinessError) {
return { ok: false, status: 400, error: error.message };
}
throw error;
}
}
+20
View File
@@ -20,6 +20,21 @@ const BCRYPT_SALT_ROUNDS = 10;
// grows into employee self-service. // grows into employee self-service.
const MIN_PASSWORD_LENGTH = 8; const MIN_PASSWORD_LENGTH = 8;
// Precomputed bcrypt hash of a fixed dummy password, hashed with the same
// cost factor (BCRYPT_SALT_ROUNDS) used for real user passwords. Used as a
// timing-safety decoy in verifyUserCredentials() below: when the looked-up
// email doesn't exist, we still run a bcrypt.compare() against this hash
// (whose result is always discarded) so that the "unknown email" code path
// takes roughly the same time as the "wrong password" path — otherwise the
// early return would let an attacker distinguish "email doesn't exist" from
// "email exists, password wrong" purely by response latency (bcrypt.compare
// dominates request time). Computed once at module load via hashSync (not
// per-request) since hashing itself is deliberately expensive.
const DUMMY_PASSWORD_HASH = bcrypt.hashSync(
"dummy-password-for-timing-safety",
BCRYPT_SALT_ROUNDS
);
export interface UserRecord { export interface UserRecord {
id: string; id: string;
email: string; email: string;
@@ -138,6 +153,11 @@ export async function verifyUserCredentials(
const user = await prisma.user.findUnique({ where: { email } }); const user = await prisma.user.findUnique({ where: { email } });
if (!user) { if (!user) {
// Run a dummy compare against a fixed decoy hash and discard the result
// — see DUMMY_PASSWORD_HASH comment above. This keeps timing consistent
// with the "email exists, password wrong" branch below instead of
// returning early.
await bcrypt.compare(password, DUMMY_PASSWORD_HASH);
return null; return null;
} }
+24
View File
@@ -297,6 +297,30 @@ describe("POST /api/assets", () => {
const remaining = await prisma.assetItem.findMany(); const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(0); expect(remaining).toHaveLength(0);
}); });
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/assets", {
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 — use a JSON string primitive here to actually exercise
// the "not an object" branch (mirrors tests/licenses.test.ts).
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/assets", () => { describe("GET /api/assets", () => {
+16
View File
@@ -115,4 +115,20 @@ describe("verifyUserCredentials", () => {
expect(await verifyUserCredentials(email, undefined)).toBeNull(); expect(await verifyUserCredentials(email, undefined)).toBeNull();
expect(await verifyUserCredentials(123, password)).toBeNull(); expect(await verifyUserCredentials(123, password)).toBeNull();
}); });
// Timing side-channel fix: an unknown email must not short-circuit to a
// different outcome shape than a known email with the wrong password —
// both must be indistinguishable (null) so a caller/attacker can't infer
// which case occurred from the result alone. (The actual timing-safety
// property — running a dummy bcrypt.compare() on the unknown-email path —
// isn't asserted here since timing isn't reliably testable in a unit
// test; this only pins down the observable behavior staying identical.)
it("returns the same result (null) for an unknown email as for a known email with the wrong password", async () => {
const unknownEmailResult = await verifyUserCredentials("[email protected]", password);
const wrongPasswordResult = await verifyUserCredentials(email, "wrong-password");
expect(unknownEmailResult).toBeNull();
expect(wrongPasswordResult).toBeNull();
expect(unknownEmailResult).toBe(wrongPasswordResult);
});
}); });
+24
View File
@@ -125,6 +125,30 @@ describe("POST /api/employees", () => {
}); });
expect(updated.managerId).toBe(created.id); expect(updated.managerId).toBe(created.id);
}); });
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/employees", {
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 — use a JSON string primitive here to actually exercise
// the "not an object" branch (mirrors tests/licenses.test.ts).
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/employees", () => { describe("GET /api/employees", () => {
+809
View File
@@ -0,0 +1,809 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
import {
closeMaintenanceRecord,
createMaintenanceRecord,
listMaintenanceRecords,
validateCloseMaintenanceRecordInput,
validateCreateMaintenanceRecordInput,
} from "@/lib/maintenance-records";
import { prisma } from "@/lib/prisma";
// This file has two layers of coverage, mirroring tests/assignments.test.ts:
// - lib-level tests exercise src/lib/maintenance-records.ts directly
// (validation, transactional business rules).
// - route-level tests exercise src/app/api/maintenance-records/route.ts and
// src/app/api/maintenance-records/[id]/route.ts over HTTP, including the
// auth() gate — mirrors tests/assignments.test.ts's @/auth mocking pattern.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/maintenance-records/route";
import { PATCH } from "@/app/api/maintenance-records/[id]/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/maintenance-records", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function patchRequest(body: unknown): Request {
return new Request("http://localhost/api/maintenance-records/x", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
// MaintenanceRecord.assetItemId is a real FK to AssetItem, which itself
// requires a full AssetModel -> AssetCategory chain. Mirrors
// tests/assignments.test.ts's createTestModelId() helper.
async function createTestModelId(): Promise<string> {
const category = await prisma.assetCategory.create({
data: { name: "Laptop", valueGroup: "high_value" },
});
const model = await prisma.assetModel.create({
data: { categoryId: category.id, manufacturer: "Dell", name: "Latitude 5440" },
});
return model.id;
}
let serialCounter = 0;
function createTestAssetItem(overrides: {
modelId: string;
status?: "in_stock" | "assigned" | "repair" | "retired";
}) {
serialCounter += 1;
return prisma.assetItem.create({
data: {
serialNumber: `SN-MAINT-${serialCounter}`,
modelId: overrides.modelId,
purchaseDate: new Date("2026-01-15"),
status: overrides.status ?? "in_stock",
},
});
}
// MaintenanceRecord.employeeId isn't a field on the model, but Assignment is
// used to exercise the "active assignment blocks maintenance" rule, which
// itself needs an Employee. Mirrors tests/assignments.test.ts's
// createTestEmployee() helper.
function createTestEmployee(overrides: { name?: string } = {}) {
return prisma.employee.create({
data: {
name: overrides.name ?? "Test Employee",
department: "IT",
},
});
}
// MaintenanceRecord.vendorId is a real (nullable) FK to Vendor. Mirrors
// tests/assignments.test.ts's createTestVendor() helper.
function createTestVendor(overrides: { name?: string } = {}) {
return prisma.vendor.create({
data: {
name: overrides.name ?? "Test Vendor",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Test Street",
},
});
}
beforeEach(async () => {
// Clear in FK-safe order: maintenance_records + assignments first
// (reference asset_items/licenses/vendors/employees), then asset_items/
// licenses (reference purchase_orders/vendors/models), then the rest.
await prisma.maintenanceRecord.deleteMany();
await prisma.assignment.deleteMany();
await prisma.assetItem.deleteMany();
await prisma.license.deleteMany();
await prisma.assetModel.deleteMany();
await prisma.assetCategory.deleteMany();
await prisma.purchaseOrder.deleteMany();
await prisma.vendor.deleteMany();
await prisma.employee.deleteMany();
serialCounter = 0;
// Default to a logged-in session — the lib-level tests don't go through
// the route at all (no-op for them), and the route-level tests exercise
// business logic, not auth gating (covered by the "auth gating" block).
mockedAuth.mockResolvedValue(testSession);
});
describe("validateCreateMaintenanceRecordInput", () => {
it("accepts valid input without vendorId", () => {
const result = validateCreateMaintenanceRecordInput({
assetItemId: "11111111-1111-1111-1111-111111111111",
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
});
expect(result.ok).toBe(true);
});
it("accepts valid input with vendorId set", () => {
const result = validateCreateMaintenanceRecordInput({
assetItemId: "11111111-1111-1111-1111-111111111111",
type: "warranty",
vendorId: "22222222-2222-2222-2222-222222222222",
issueDescription: "Battery swollen",
startDate: "2026-08-01",
});
expect(result.ok).toBe(true);
});
it("rejects a missing assetItemId", () => {
const result = validateCreateMaintenanceRecordInput({
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/assetItemId/i);
}
});
it("rejects an invalid type", () => {
const result = validateCreateMaintenanceRecordInput({
assetItemId: "11111111-1111-1111-1111-111111111111",
type: "not-a-type",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/type/i);
}
});
});
describe("createMaintenanceRecord", () => {
it("opens a maintenance record, flips AssetItem.status to repair", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: new Date("2026-08-01"),
});
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.asset_item_id).toBe(assetItem.id);
expect(created.value.vendor_id).toBeNull();
expect(created.value.end_date).toBeNull();
const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(updatedItem.status).toBe("repair");
});
it("creates successfully without a vendorId", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "preventive",
issueDescription: "Routine cleaning",
startDate: new Date("2026-08-01"),
});
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.vendor_id).toBeNull();
});
it("creates successfully with a vendorId", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const vendor = await createTestVendor();
const created = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "warranty",
vendorId: vendor.id,
issueDescription: "Battery swollen",
startDate: new Date("2026-08-01"),
});
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.vendor_id).toBe(vendor.id);
});
it("rejects opening a record for an AssetItem that has an active assignment", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId, status: "assigned" });
const employee = await createTestEmployee();
await prisma.assignment.create({
data: { employeeId: employee.id, assetItemId: assetItem.id, assignedAt: new Date() },
});
const created = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: new Date("2026-08-01"),
});
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/active assignment/i);
const remaining = await prisma.maintenanceRecord.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects opening a record for an AssetItem that is already retired", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId, status: "retired" });
const created = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: new Date("2026-08-01"),
});
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/retired/i);
const remaining = await prisma.maintenanceRecord.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects opening a second record while one is already open for the same AssetItem", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const first = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: new Date("2026-08-01"),
});
expect(first.ok).toBe(true);
const second = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "preventive",
issueDescription: "Another issue",
startDate: new Date("2026-08-02"),
});
expect(second.ok).toBe(false);
if (second.ok) return;
expect(second.error).toMatch(/already has an open maintenance record/i);
const records = await prisma.maintenanceRecord.findMany({ where: { assetItemId: assetItem.id } });
expect(records).toHaveLength(1);
});
});
describe("closeMaintenanceRecord", () => {
it.each(["in_stock", "retired"] as const)(
"closes a record and applies targetStatus %s to the AssetItem",
async (targetStatus) => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: new Date("2026-08-01"),
});
expect(created.ok).toBe(true);
if (!created.ok) return;
const closed = await closeMaintenanceRecord(created.value.id, {
endDate: new Date("2026-08-05"),
targetStatus,
});
expect(closed.ok).toBe(true);
if (!closed.ok) return;
expect(closed.value.end_date).not.toBeNull();
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe(targetStatus);
}
);
it("rejects double-closing a record", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: new Date("2026-08-01"),
});
expect(created.ok).toBe(true);
if (!created.ok) return;
const firstClose = await closeMaintenanceRecord(created.value.id, {
endDate: new Date("2026-08-05"),
targetStatus: "in_stock",
});
expect(firstClose.ok).toBe(true);
const secondClose = await closeMaintenanceRecord(created.value.id, {
endDate: new Date("2026-08-06"),
targetStatus: "in_stock",
});
expect(secondClose.ok).toBe(false);
if (secondClose.ok) return;
expect(secondClose.status).toBe(400);
expect(secondClose.error).toMatch(/already been closed/i);
});
it("rejects an endDate before the record's startDate", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: new Date("2026-08-05"),
});
expect(created.ok).toBe(true);
if (!created.ok) return;
const closed = await closeMaintenanceRecord(created.value.id, {
endDate: new Date("2026-08-01"),
targetStatus: "in_stock",
});
expect(closed.ok).toBe(false);
if (closed.ok) return;
expect(closed.status).toBe(400);
expect(closed.error).toMatch(/endDate must be on or after startDate/i);
});
it("returns 404 for a nonexistent record id", async () => {
const result = await closeMaintenanceRecord("00000000-0000-0000-0000-000000000000", {
endDate: new Date("2026-08-05"),
targetStatus: "in_stock",
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(404);
expect(result.error).toMatch(/not found/i);
});
});
describe("validateCloseMaintenanceRecordInput", () => {
it("accepts valid input", () => {
const result = validateCloseMaintenanceRecordInput({
endDate: "2026-08-05",
targetStatus: "in_stock",
});
expect(result.ok).toBe(true);
});
it("rejects a missing targetStatus", () => {
const result = validateCloseMaintenanceRecordInput({ endDate: "2026-08-05" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/targetStatus/i);
}
});
it("rejects a missing endDate", () => {
const result = validateCloseMaintenanceRecordInput({ targetStatus: "in_stock" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/endDate/i);
}
});
});
describe("listMaintenanceRecords", () => {
it("lists created records, newest first", async () => {
const modelId = await createTestModelId();
const item1 = await createTestAssetItem({ modelId });
const item2 = await createTestAssetItem({ modelId });
const first = await createMaintenanceRecord({
assetItemId: item1.id,
type: "repair",
issueDescription: "Issue 1",
startDate: new Date("2026-08-01"),
});
expect(first.ok).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 5));
const second = await createMaintenanceRecord({
assetItemId: item2.id,
type: "repair",
issueDescription: "Issue 2",
startDate: new Date("2026-08-01"),
});
expect(second.ok).toBe(true);
const records = await listMaintenanceRecords();
expect(records).toHaveLength(2);
expect(records[0].asset_item_id).toBe(item2.id);
expect(records[1].asset_item_id).toBe(item1.id);
});
it("returns an empty list when there are no records", async () => {
const records = await listMaintenanceRecords();
expect(records).toEqual([]);
});
});
describe("POST /api/maintenance-records", () => {
it("creates a record on valid input", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const res = await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({
asset_item_id: assetItem.id,
type: "repair",
vendor_id: null,
end_date: null,
});
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("repair");
});
it("returns the full expected snake_case shape", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const vendor = await createTestVendor();
const res = await POST(
postRequest({
assetItemId: assetItem.id,
type: "warranty",
vendorId: vendor.id,
issueDescription: "Battery swollen",
startDate: "2026-08-01",
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({
id: expect.any(String),
asset_item_id: assetItem.id,
type: "warranty",
vendor_id: vendor.id,
issue_description: "Battery swollen",
resolution_notes: null,
start_date: expect.any(String),
end_date: null,
created_at: expect.any(String),
});
});
it("rejects creating a record for an AssetItem with an active assignment", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId, status: "assigned" });
const employee = await createTestEmployee();
await prisma.assignment.create({
data: { employeeId: employee.id, assetItemId: assetItem.id, assignedAt: new Date() },
});
const res = await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/active assignment/i);
});
it("rejects creating a record for a retired AssetItem", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId, status: "retired" });
const res = await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/retired/i);
});
it("rejects creating a second open record for the same AssetItem", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
})
);
const res = await POST(
postRequest({
assetItemId: assetItem.id,
type: "preventive",
issueDescription: "Another issue",
startDate: "2026-08-02",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/already has an open maintenance record/i);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/maintenance-records", {
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 (string)", async () => {
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);
});
it("rejects a non-object JSON body (number)", async () => {
const res = await POST(postRequest(123));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
});
describe("GET /api/maintenance-records", () => {
it("lists created records, newest first", async () => {
const modelId = await createTestModelId();
const item1 = await createTestAssetItem({ modelId });
const item2 = await createTestAssetItem({ modelId });
await POST(
postRequest({
assetItemId: item1.id,
type: "repair",
issueDescription: "Issue 1",
startDate: "2026-08-01",
})
);
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(
postRequest({
assetItemId: item2.id,
type: "repair",
issueDescription: "Issue 2",
startDate: "2026-08-01",
})
);
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveLength(2);
expect(body[0].asset_item_id).toBe(item2.id);
expect(body[1].asset_item_id).toBe(item1.id);
});
it("returns an empty list when there are no records", async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
it("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" });
});
});
describe("PATCH /api/maintenance-records/[id]", () => {
it.each(["in_stock", "retired"] as const)(
"closes a record with targetStatus %s",
async (targetStatus) => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
})
);
const created = await createRes.json();
const res = await PATCH(
patchRequest({ endDate: "2026-08-05", targetStatus }),
{ params: { id: created.id } }
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.end_date).not.toBeNull();
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe(targetStatus);
}
);
it("returns 400 when targetStatus is missing", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
})
);
const created = await createRes.json();
const res = await PATCH(patchRequest({ endDate: "2026-08-05" }), {
params: { id: created.id },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/targetStatus/i);
});
it("rejects double-closing a record with 400", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
})
);
const created = await createRes.json();
await PATCH(patchRequest({ endDate: "2026-08-05", targetStatus: "in_stock" }), {
params: { id: created.id },
});
const res = await PATCH(patchRequest({ endDate: "2026-08-06", targetStatus: "in_stock" }), {
params: { id: created.id },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/already been closed/i);
});
it("rejects an endDate before startDate with 400", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-05",
})
);
const created = await createRes.json();
const res = await PATCH(patchRequest({ endDate: "2026-08-01", targetStatus: "in_stock" }), {
params: { id: created.id },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/endDate must be on or after startDate/i);
});
it("returns 404 for a nonexistent record id", async () => {
const res = await PATCH(patchRequest({ endDate: "2026-08-05", targetStatus: "in_stock" }), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toMatch(/not found/i);
});
it("returns 400 for a malformed id (not a UUID)", async () => {
const res = await PATCH(patchRequest({ endDate: "2026-08-05", targetStatus: "in_stock" }), {
params: { id: "not-a-uuid" },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/UUID/i);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/maintenance-records/x", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await PATCH(req, { params: { id: "00000000-0000-0000-0000-000000000000" } });
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body (string)", async () => {
const res = await PATCH(patchRequest("not an object"), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
it("rejects a non-object JSON body (number)", async () => {
const res = await PATCH(patchRequest(123), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
it("returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await PATCH(patchRequest({ endDate: "2026-08-05", targetStatus: "in_stock" }), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
});
});
describe("auth gating on /api/maintenance-records", () => {
it("POST /api/maintenance-records returns 401 without a session", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
mockedAuth.mockResolvedValue(null);
const res = await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
})
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
const remaining = await prisma.maintenanceRecord.findMany();
expect(remaining).toHaveLength(0);
});
});
+24
View File
@@ -239,6 +239,30 @@ describe("POST /api/requests", () => {
const body = await res.json(); const body = await res.json();
expect(body.error).toMatch(/purchase_order_id/i); expect(body.error).toMatch(/purchase_order_id/i);
}); });
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/requests", {
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 — use a JSON string primitive here to actually exercise
// the "not an object" branch (mirrors tests/licenses.test.ts).
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/requests", () => { describe("GET /api/requests", () => {
+62 -19
View File
@@ -1,18 +1,46 @@
// Creates a throwaway Postgres database per test-file run against the // Creates a throwaway Postgres database per test-file run against the
// docker-compose `db` service, points DATABASE_URL at it so tests never // docker-compose `db` service, and drops it afterwards. Mirrors the old
// touch a shared dev database, and drops it afterwards. Mirrors the old
// sqlite temp-file-per-run isolation, adapted to Postgres (CREATE/DROP // sqlite temp-file-per-run isolation, adapted to Postgres (CREATE/DROP
// DATABASE instead of a throwaway file). // DATABASE instead of a throwaway file).
// //
// Creating/dropping the throwaway database itself stays raw `pg` (test // Creating/dropping the throwaway database itself stays raw `pg` (test
// infra, not the app's data-access layer) — but applying the schema to the // infra, not the app's data-access layer) — but applying the schema to the
// fresh database, and tearing down the pooled connection afterwards, now // fresh database goes through `prisma migrate deploy`, and the actual
// goes through Prisma instead of the old db.ts. // per-file PrismaClient used by both test bodies and app code (route
// handlers etc., all of which import `prisma` from "@/lib/prisma") is a
// throwaway PrismaClient instance created here, wired up explicitly.
//
// Root-cause note (2026-08-04): this used to work by mutating the global
// `process.env.DATABASE_URL` in a `beforeAll` and relying on the app's
// `src/lib/prisma.ts` singleton to pick that up. That's unsafe the moment
// two test files' setup/query timing can interleave within the same
// worker process — `process.env` is shared across all test files that
// happen to run in the same worker (Vitest's `test.isolate` resets each
// file's *module registry*, not `process.env`), so one file's mutation can
// leak into another's PrismaClient construction/reconnect. That forced
// `fileParallelism: false` as a blunt workaround (see git blame on
// vitest.config.ts), and even that stopped being sufficient once the
// worker pool got reused across enough sequential files (12 test files —
// see git history around this comment for the concrete FK-violation
// failures that motivated this rewrite).
//
// Fix: never touch `process.env.DATABASE_URL` at all. Instead, `vi.mock`
// "@/lib/prisma" (the same module id every test file and every app module
// imports `prisma` from) with a factory that creates this file's own
// throwaway database and returns a dedicated `PrismaClient` pointed at it
// via the explicit `datasourceUrl` constructor option (Prisma 5.2+) —
// never via the ambient env var. `vi.mock` factories are cached per
// isolated test-file module registry, so every import of "@/lib/prisma"
// within a given test file (the test file itself, and any route handler /
// lib module it pulls in) resolves to that one file-scoped client, while
// two different test files never share any mutable global state. That
// makes `fileParallelism: true` (the default, restored in
// vitest.config.ts) safe again.
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { Client } from "pg"; import { Client } from "pg";
import { afterAll, beforeAll } from "vitest"; import { PrismaClient } from "@prisma/client";
import { prisma } from "@/lib/prisma"; import { afterAll, vi } from "vitest";
const baseUrl = new URL( const baseUrl = new URL(
process.env.DATABASE_URL || process.env.DATABASE_URL ||
@@ -26,6 +54,12 @@ function adminUrl(): string {
return url.toString(); return url.toString();
} }
function testDbUrl(): string {
const url = new URL(baseUrl.toString());
url.pathname = `/${testDbName}`;
return url.toString();
}
async function withAdminClient<T>(fn: (client: Client) => Promise<T>): Promise<T> { async function withAdminClient<T>(fn: (client: Client) => Promise<T>): Promise<T> {
const client = new Client({ connectionString: adminUrl() }); const client = new Client({ connectionString: adminUrl() });
await client.connect(); await client.connect();
@@ -36,27 +70,36 @@ async function withAdminClient<T>(fn: (client: Client) => Promise<T>): Promise<T
} }
} }
beforeAll(async () => { // Set by the "@/lib/prisma" mock factory below once it has created this
await withAdminClient((client) => client.query(`CREATE DATABASE ${testDbName}`)); // file's throwaway database and client, so afterAll can tear both down.
// Populated synchronously-in-effect before any test body can run, since
// the mock factory is awaited as part of resolving the first import of
// "@/lib/prisma" (which every test file and every route/lib module under
// test does at the top of its own module graph, ahead of any test body).
let testPrisma: PrismaClient | undefined;
const testUrl = new URL(baseUrl.toString()); vi.mock("@/lib/prisma", async () => {
testUrl.pathname = `/${testDbName}`; await withAdminClient((client) => client.query(`CREATE DATABASE ${testDbName}`));
process.env.DATABASE_URL = testUrl.toString();
// Apply the Prisma migration history to the fresh throwaway database. // Apply the Prisma migration history to the fresh throwaway database.
// `migrate deploy` replays the same real migrations production uses // `migrate deploy` replays the same real migrations production uses
// (prisma/migrations), rather than a schema-diffing shortcut like // (prisma/migrations), rather than a schema-diffing shortcut like
// `db push` — keeping this test setup honest about what actually runs // `db push` — keeping this test setup honest about what actually runs
// outside tests too. // outside tests too. Pass the target database explicitly via the
execSync("npx prisma migrate deploy", { stdio: "inherit" }); // subprocess's own `env` rather than mutating this process's
}, 30_000); // `process.env` — the whole point of this rewrite is to avoid any
// shared mutable global between test files.
execSync("npx prisma migrate deploy", {
stdio: "inherit",
env: { ...process.env, DATABASE_URL: testDbUrl() },
});
testPrisma = new PrismaClient({ datasourceUrl: testDbUrl() });
return { prisma: testPrisma };
});
afterAll(async () => { afterAll(async () => {
// Disconnect the app's Prisma Client singleton first so the admin await testPrisma?.$disconnect();
// connection is free to drop the database (DROP DATABASE ... WITH
// (FORCE) would also disconnect it, but closing cleanly first avoids
// relying on that).
await prisma.$disconnect();
await withAdminClient((client) => await withAdminClient((client) =>
client.query(`DROP DATABASE IF EXISTS ${testDbName} WITH (FORCE)`) client.query(`DROP DATABASE IF EXISTS ${testDbName} WITH (FORCE)`)
); );
+17 -13
View File
@@ -9,19 +9,23 @@ export default defineConfig({
}, },
test: { test: {
environment: "node", environment: "node",
// Scope explicitly to this repo's own tests/ dir — without this, Vitest's
// default glob also picks up any stray sibling git worktree checked out
// under .claude/worktrees/ (e.g. worktree-ui-basic), which isn't excluded
// by Vitest's defaults since those only skip node_modules/.git/etc, not
// .claude/. A stray worktree's stale test file still resolves the "@"
// alias to *this* repo's src/ (see resolve.alias above, which is
// __dirname-relative), so it silently runs old tests against current
// code under a shared identity — confirmed happening in practice once a
// second worktree existed.
include: ["tests/**/*.test.ts"],
setupFiles: ["./tests/setup.ts"], setupFiles: ["./tests/setup.ts"],
// Each test file spins up its own throwaway Postgres database by // Each test file gets its own throwaway Postgres database and its own
// mutating process.env.DATABASE_URL in tests/setup.ts's beforeAll (see // dedicated PrismaClient, wired up via a `vi.mock("@/lib/prisma", ...)`
// that file). That per-file DB switch is not safe under Vitest's default // factory in tests/setup.ts (not by mutating the ambient
// parallel file execution: two files' beforeAll/query timing can // process.env.DATABASE_URL, which used to be shared/racy across files —
// interleave such that both end up issuing real queries against the // see the long comment at the top of tests/setup.ts for the history).
// same throwaway DB (confirmed empirically once Request gained a real // With no mutable state shared between files, parallel file execution
// FK to Employee — tests/employees.test.ts and tests/requests.test.ts // (Vitest's default) is safe again.
// started intermittently deleting/reading each other's rows, causing
// spurious FK-violation and row-count failures that disappear when run
// sequentially). Disabling file parallelism trades a bit of wall-clock
// test time for correctness — each file's throwaway DB is now genuinely
// private for the duration of that file's run.
fileParallelism: false,
}, },
}); });