Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e30489297c | ||
|
|
d700eaa6a9 | ||
|
|
713dfad364 | ||
|
|
74e78322b4 | ||
|
|
651ff2d626 | ||
|
|
b702528e19 | ||
|
|
be47eb69d3 | ||
|
|
2a0acd11a9 | ||
|
|
44dd33dd3b | ||
|
|
7e1351bde2 | ||
|
|
91624d5f07 | ||
|
|
dfbee1a00d | ||
|
|
7f4a727669 | ||
|
|
67fe335e76 | ||
|
|
5089bd6dfc | ||
|
|
d557694f77 | ||
|
|
e1ad9da72d | ||
|
|
d6cea577c9 | ||
|
|
a9dafcc140 |
@@ -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
|
||||
|
||||
- **Task 1 xong**: đã bootstrap Next.js (TS, App Router) project thật, entity `Request` (chỉ state `Draft`) với `POST/GET /api/requests`.
|
||||
- **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.
|
||||
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).
|
||||
|
||||
- **`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ở
|
||||
|
||||
- `fileParallelism: false` (fix race condition test-infra) là fix đúng nhưng "cùn" — chặn song song **toàn bộ** suite thay vì chỉ fix gốc rễ (`tests/setup.ts` ghi đè `process.env.DATABASE_URL` toàn cục thay vì truyền connection string riêng cho từng Prisma Client instance). Test suite sẽ chạy chậm dần tuyến tính khi thêm entity mới (`AssetItem`, `License`...). Cân nhắc refactor `tests/setup.ts` để mỗi file tự có Prisma Client riêng trỏ đúng DB tạm của mình, rồi bật lại parallelism — chưa gấp ở quy mô hiện tại (3 file, ~15s).
|
||||
- Fix timing side-channel ở `verifyUserCredentials` (xem note ở "Trạng thái hiện tại").
|
||||
- Cải thiện `scripts/create-user.ts` để password không lộ qua CLI arg (đọc từ stdin/prompt thay vì `--password`).
|
||||
- Bump `vitest` lên 4.x (fix advisory critical GHSA-5xrq-8626-4rwp — hiện không khai thác được vì project không chạy `vitest --ui`, nhưng nên dọn khi có breaking-change window) — không khẩn cấp.
|
||||
- Rà lại advisory riêng của `next` (prod dependency, có vài high/moderate — vd SSRF qua WebSocket upgrade, middleware bypass) khi có dịp, tách biệt khỏi đợt retrofit Prisma/Zod này.
|
||||
- Test coverage gaps (chưa block gì, chỉ là follow-up): chưa test malformed JSON body, chưa test JSON body không phải object (array/string).
|
||||
- `Button` (shadcn) không `forwardRef`, React đang pin 18 — dormant risk, xem note ở "Trạng thái hiện tại".
|
||||
- **`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ẽ):**
|
||||
- **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.
|
||||
- **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)).
|
||||
- **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.
|
||||
- **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, 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
|
||||
- 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)
|
||||
- Fix timing side-channel ở `verifyUserCredentials`: trả về sớm khi email không tồn tại (bỏ qua bcrypt compare) → thời gian phản hồi khác nhau giữa "email sai" vs "password sai", có thể dò được email tồn tại. Fix: luôn chạy `bcrypt.compare()` kể cả khi không tìm thấy user (so với 1 hash giả cố định).
|
||||
- Cải thiện `scripts/create-user.ts` để password không lộ qua CLI arg (đọc từ stdin/prompt thay vì `--password`) — chấp nhận được vì chỉ IT admin tự chạy tay, không phải self-registration công khai.
|
||||
- Bump `vitest` lên 4.x (fix advisory critical GHSA-5xrq-8626-4rwp — không khai thác được vì project không chạy `vitest --ui`) — không khẩn cấp.
|
||||
- Rà lại advisory riêng của `next` (vài high/moderate — SSRF qua WebSocket upgrade, middleware bypass) khi có dịp.
|
||||
- Test coverage gap: `assets`/`employees`/`requests` chưa có test malformed JSON body / non-object body (`licenses`/`assignments` đã có).
|
||||
- `Button` (shadcn) không `forwardRef` (registry hiện tại target React 19), project pin React 18.3.1 — dormant risk, chỉ phát sinh nếu sau này có chỗ truyền `ref` vào `<Button>`.
|
||||
- **Finding từ code-reviewer gate cho `Assignment` (2026-08-03), cả hai đều dormant/chưa gãy vì chưa có delete endpoint nào và `createAssignment()` hiện chỉ được gọi qua route đã validate:**
|
||||
- XOR (`assetItemId`/`licenseId`) chỉ enforce ở tầng Zod (route boundary), không lặp lại bên trong `createAssignment()` — nếu sau này có caller khác gọi thẳng lib function (script, bulk-import) bỏ qua validate, có thể tạo Assignment vi phạm XOR.
|
||||
- FK `assetItemId`/`licenseId` trên `Assignment` dùng `ON DELETE SET NULL`, không nhất quán với `employeeId` (`ON DELETE RESTRICT`) — nếu sau này có endpoint xoá `AssetItem`/`License`, các `Assignment` lịch sử tham chiếu tới nó sẽ bị null hoá cả 2 field, xoá mất thông tin đã từng assign gì (và re-vi-phạm XOR retroactively). Nên đổi sang `RESTRICT` như `employeeId` khi/nếu thêm delete endpoint.
|
||||
- (LOW, chưa cần fix) `returnedAt` không validate `>= assignedAt`; decrement `License.seatsUsed` lúc return không có floor guard (an toàn hiện tại vì luôn đi kèm 1 increment trước đó, nhưng không có defensive floor nếu state drift do can thiệp DB tay).
|
||||
- **Finding LOW từ code-reviewer gate cho `MaintenanceRecord` (2026-08-04), chưa fix (chấp nhận được, dormant):** `closeMaintenanceRecord()`'s update cuối cùng lên `AssetItem.status` là `update()` thường, không có WHERE-guard theo trạng thái hiện tại — cùng loại rủi ro dormant như `Assignment.returnedAt`/`seatsUsed` ở trên, chỉ phát sinh nếu có flow khác ghi `AssetItem.status` đồng thời lúc đóng record (hiện chưa có).
|
||||
- `ConsumableItem` (phần catalog, low-value) đã có spec (`docs/asset-license-management.md`) nhưng **chưa build** — sẵn sàng giao `pm` khi cần. Luồng "xin+nhận" consumable riêng (đơn giản hơn state machine `Request`) vẫn **BỊ CHẶN** chờ stakeholder trả lời 7 câu hỏi mở (xem doc).
|
||||
- Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled.
|
||||
- UI cho các state Request kế tiếp (hiện chỉ có Draft) — chờ spec/build state machine ở trên trước. (Lưu ý: có thể đã có tiến triển từ 1 phiên Claude Code khác chạy song song — kiểm tra `git status`/working tree trước khi bắt đầu để tránh làm trùng.)
|
||||
- Auth/phân quyền (ai được approve request, ai được xác nhận nhận hàng/bàn giao) — vẫn flat, chưa có role.
|
||||
|
||||
---
|
||||
Cập nhật lần cuối: 2026-08-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)
|
||||
|
||||
@@ -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 |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
@@ -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).
|
||||
- `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)
|
||||
|
||||
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)
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
- 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
|
||||
- **`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
|
||||
- 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.
|
||||
|
||||
---
|
||||
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
@@ -125,6 +125,11 @@ model AssetItem {
|
||||
// multiple Assignment rows over its lifetime (historical + at most one
|
||||
// active, see Assignment model's comment), not built until this task.
|
||||
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")
|
||||
}
|
||||
@@ -194,6 +199,10 @@ model Vendor {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
licenses License[]
|
||||
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")
|
||||
}
|
||||
@@ -389,9 +398,9 @@ model Assignment {
|
||||
employeeId String @map("employee_id") @db.Uuid
|
||||
employee Employee @relation(fields: [employeeId], references: [id])
|
||||
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
|
||||
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)
|
||||
returnedAt DateTime? @map("returned_at") @db.Timestamptz(6)
|
||||
signedAt DateTime? @map("signed_at") @db.Timestamptz(6)
|
||||
@@ -417,3 +426,50 @@ model User {
|
||||
|
||||
@@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
@@ -1,8 +1,12 @@
|
||||
// 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
|
||||
// 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).
|
||||
import readline from "readline";
|
||||
import { createUser, validateCreateUserInput } from "../src/lib/users";
|
||||
import { prisma } from "../src/lib/prisma";
|
||||
|
||||
@@ -24,19 +28,91 @@ function parseArgs(argv: string[]): Record<string, string> {
|
||||
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> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const password = await readPassword("Password: ");
|
||||
|
||||
const validation = validateCreateUserInput({
|
||||
email: args.email,
|
||||
name: args.name,
|
||||
password: args.password,
|
||||
password,
|
||||
});
|
||||
|
||||
if (!validation.ok) {
|
||||
console.error(`Error: ${validation.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;
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/auth";
|
||||
import { returnAssignment, validateReturnAssignmentInput } from "@/lib/assignments";
|
||||
import {
|
||||
returnAssignment,
|
||||
transferAssignment,
|
||||
validateReturnAssignmentInput,
|
||||
validateTransferAssignmentInput,
|
||||
} from "@/lib/assignments";
|
||||
|
||||
// First dynamic-segment ([id]) route in the codebase — see
|
||||
// docs/asset-license-management.md's "Assignment (chốt 2026-08-02)" section,
|
||||
@@ -39,7 +44,34 @@ export async function PATCH(
|
||||
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { returnedAt } = body as Record<string, unknown>;
|
||||
const { returnedAt, employeeId } = body as Record<string, unknown>;
|
||||
|
||||
// Dispatch on body shape — a body carrying employeeId is a transfer
|
||||
// request, everything else is the existing return behavior. The two are
|
||||
// mutually exclusive: don't guess intent if a caller sends both (see
|
||||
// docs/asset-license-management.md's "Assignment (chốt 2026-08-02)"
|
||||
// section, bullet right after the transfer rule, for why this stays a
|
||||
// single PATCH route rather than an action-based sub-route).
|
||||
if (employeeId !== undefined && returnedAt !== undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide either employeeId (transfer) or returnedAt (return), not both" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (employeeId !== undefined) {
|
||||
const transferValidation = validateTransferAssignmentInput({ employeeId });
|
||||
if (!transferValidation.ok) {
|
||||
return NextResponse.json({ error: transferValidation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const transferred = await transferAssignment(params.id, transferValidation.value);
|
||||
if (!transferred.ok) {
|
||||
return NextResponse.json({ error: transferred.error }, { status: transferred.status });
|
||||
}
|
||||
return NextResponse.json(transferred.value, { status: 200 });
|
||||
}
|
||||
|
||||
const validation = validateReturnAssignmentInput({ returnedAt });
|
||||
|
||||
if (!validation.ok) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -88,6 +88,14 @@ export default function AssignmentsPage() {
|
||||
const [returnErrors, setReturnErrors] = useState<Record<string, string>>({});
|
||||
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>) {
|
||||
event.preventDefault();
|
||||
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 (
|
||||
<div className="flex flex-col gap-8 p-6">
|
||||
<h1 className="text-xl font-semibold">Assignments</h1>
|
||||
@@ -220,6 +283,7 @@ export default function AssignmentsPage() {
|
||||
<TableCell>{assignment.created_at}</TableCell>
|
||||
<TableCell>
|
||||
{assignment.returned_at === null ? (
|
||||
<div className="flex w-56 flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -236,6 +300,47 @@ export default function AssignmentsPage() {
|
||||
</p>
|
||||
) : null}
|
||||
</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>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const ENTITY_LINKS = [
|
||||
{ href: "/purchase-orders", label: "Purchase Orders" },
|
||||
{ href: "/requests", label: "Requests" },
|
||||
{ href: "/assignments", label: "Assignments" },
|
||||
{ href: "/maintenance-records", label: "Maintenance Records" },
|
||||
] as const;
|
||||
|
||||
export function Nav() {
|
||||
|
||||
+127
-3
@@ -43,15 +43,21 @@ function dateString(message: string) {
|
||||
.transform((value) => new Date(value));
|
||||
}
|
||||
|
||||
// Shared between createAssignmentSchema's employeeId field and
|
||||
// transferAssignmentSchema below — kept as a standalone Zod schema (rather
|
||||
// than inlined in both places) so the two stay byte-for-byte in sync on the
|
||||
// exact error message, instead of two copies drifting apart over time.
|
||||
const employeeIdSchema = z
|
||||
.string({ required_error: "employeeId is required and must be a valid UUID string" })
|
||||
.uuid("employeeId is required and must be a valid UUID string");
|
||||
|
||||
const createAssignmentSchema = z
|
||||
.object({
|
||||
// FK to Employee — only a UUID *format* check here, whether it
|
||||
// references a real employee is left to Prisma's own FK constraint at
|
||||
// insert time (see createAssignment()'s P2003 handling below). Mirrors
|
||||
// src/lib/requests.ts's employee_id handling.
|
||||
employeeId: z
|
||||
.string({ required_error: "employeeId is required and must be a valid UUID string" })
|
||||
.uuid("employeeId is required and must be a valid UUID string"),
|
||||
employeeId: employeeIdSchema,
|
||||
// Exactly one of assetItemId/licenseId must be set — see the .refine()
|
||||
// below for the XOR check. Unlike other nullable FKs in this codebase
|
||||
// (e.g. AssetItem.purchaseOrderId), whether these reference a real row
|
||||
@@ -137,6 +143,35 @@ export function validateReturnAssignmentInput(input: {
|
||||
return { ok: true, value: result.data };
|
||||
}
|
||||
|
||||
const transferAssignmentSchema = z.object({
|
||||
// Reuses employeeIdSchema (see its doc comment above) — the new employee
|
||||
// to hand the same AssetItem/License over to. assetItemId/licenseId are
|
||||
// deliberately NOT accepted here: transferAssignment() reuses whichever
|
||||
// one the original Assignment already had (see that function's doc
|
||||
// comment), the caller doesn't get to change it via a transfer.
|
||||
employeeId: employeeIdSchema,
|
||||
});
|
||||
|
||||
export type TransferValidationResult =
|
||||
| { ok: true; value: { employeeId: string } }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Validates raw (untyped) assignment-transfer input. employeeId must be a
|
||||
* valid UUID string (same format check as validateCreateAssignmentInput's
|
||||
* employeeId field).
|
||||
*/
|
||||
export function validateTransferAssignmentInput(input: {
|
||||
employeeId?: unknown;
|
||||
}): TransferValidationResult {
|
||||
const result = transferAssignmentSchema.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;
|
||||
employeeId: string;
|
||||
@@ -407,3 +442,92 @@ export async function returnAssignment(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export type TransferAssignmentResult =
|
||||
| { ok: true; value: AssignmentRecord }
|
||||
| { ok: false; status: 404 | 400; error: string };
|
||||
|
||||
/**
|
||||
* Transfers an Assignment's underlying AssetItem/License to a new employee —
|
||||
* docs/asset-license-management.md's "## Assignment (chốt 2026-08-02)":
|
||||
* "Chuyển tài sản giữa nhân viên ... = đóng Assignment cũ (returnedAt = now)
|
||||
* + tạo Assignment mới, cả hai trong 1 transaction." Closes the existing
|
||||
* Assignment row (returnedAt = now) and creates a brand-new one for the new
|
||||
* employee, reusing the SAME assetItemId/licenseId as the original, all
|
||||
* inside a single `prisma.$transaction()`.
|
||||
*
|
||||
* Deliberately does NOT touch AssetItem.status or License.seatsUsed — unlike
|
||||
* createAssignment()/returnAssignment(), a transfer is a same-resource
|
||||
* handover, not a release-then-reacquire: the AssetItem never becomes
|
||||
* "in_stock" and the License seat is never freed at any point during a
|
||||
* transfer (no race window for a third party to grab either one mid-transfer
|
||||
* — that's the whole point of doing this in one transaction instead of a
|
||||
* separate PATCH-return followed by a POST-create, which is exactly the gap
|
||||
* this function closes). AssetItem.status stays "assigned" throughout;
|
||||
* seatsUsed is net zero (same seat, new holder).
|
||||
*
|
||||
* Race-condition note: mirrors returnAssignment()'s exact race-closing
|
||||
* pattern — double-transfer (or transfer-racing-return) is closed by the
|
||||
* same atomic, WHERE-guarded `updateMany` (`where: { id, returnedAt: null }`)
|
||||
* on the OLD Assignment row. Only the first caller to commit gets
|
||||
* `count: 1`; a second, concurrent call against the same id gets `count: 0`
|
||||
* and is rejected as "already returned" — same semantics as
|
||||
* returnAssignment(), since from the old Assignment row's point of view a
|
||||
* transfer's first half *is* a return. The `findUnique` right before the
|
||||
* `updateMany` is NOT the race-closing check by itself (same caveat as
|
||||
* createAssignment()/returnAssignment()'s doc comments) — it only exists to
|
||||
* read assetItemId/licenseId for the new row and to distinguish "not found"
|
||||
* from "already returned" in the common (non-racing) case.
|
||||
*/
|
||||
export async function transferAssignment(
|
||||
id: string,
|
||||
input: { employeeId: string }
|
||||
): Promise<TransferAssignmentResult> {
|
||||
try {
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
const assignment = await tx.assignment.findUnique({ where: { id } });
|
||||
if (!assignment) {
|
||||
throw new AssignmentNotFoundError();
|
||||
}
|
||||
|
||||
// Atomic race-closing gate — see this function's doc comment. Also
|
||||
// doubles as the "already returned" rejection in the non-racing case.
|
||||
const claim = await tx.assignment.updateMany({
|
||||
where: { id, returnedAt: null },
|
||||
data: { returnedAt: new Date() },
|
||||
});
|
||||
if (claim.count === 0) {
|
||||
throw new AssignmentBusinessError("Assignment has already been returned");
|
||||
}
|
||||
|
||||
return tx.assignment.create({
|
||||
data: {
|
||||
employeeId: input.employeeId,
|
||||
assetItemId: assignment.assetItemId,
|
||||
licenseId: assignment.licenseId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return { ok: true, value: toRecord(created) };
|
||||
} catch (error) {
|
||||
if (error instanceof AssignmentNotFoundError) {
|
||||
return { ok: false, status: 404, error: "Assignment not found" };
|
||||
}
|
||||
if (error instanceof AssignmentBusinessError) {
|
||||
return { ok: false, status: 400, error: error.message };
|
||||
}
|
||||
// Only employeeId can fail with a raw FK violation here — assetItemId/
|
||||
// licenseId are reused verbatim from an already-valid Assignment row,
|
||||
// so they're guaranteed to still reference existing rows (nothing in
|
||||
// this codebase deletes AssetItem/License rows that have Assignments).
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "employeeId does not reference an existing employee",
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +20,21 @@ const BCRYPT_SALT_ROUNDS = 10;
|
||||
// grows into employee self-service.
|
||||
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 {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -138,6 +153,11 @@ export async function verifyUserCredentials(
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -297,6 +297,30 @@ describe("POST /api/assets", () => {
|
||||
const remaining = await prisma.assetItem.findMany();
|
||||
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", () => {
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
createAssignment,
|
||||
listAssignments,
|
||||
returnAssignment,
|
||||
transferAssignment,
|
||||
validateCreateAssignmentInput,
|
||||
validateReturnAssignmentInput,
|
||||
validateTransferAssignmentInput,
|
||||
} from "@/lib/assignments";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -542,6 +544,149 @@ describe("returnAssignment", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("transferAssignment", () => {
|
||||
it("transfers an AssetItem assignment to a new employee, keeping status assigned throughout", async () => {
|
||||
const employee1 = await createTestEmployee({ name: "Employee 1" });
|
||||
const employee2 = await createTestEmployee({ name: "Employee 2" });
|
||||
const modelId = await createTestModelId();
|
||||
const assetItem = await createTestAssetItem({ modelId });
|
||||
|
||||
const created = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
|
||||
const transferred = await transferAssignment(created.value.id, { employeeId: employee2.id });
|
||||
expect(transferred.ok).toBe(true);
|
||||
if (!transferred.ok) return;
|
||||
expect(transferred.value.employee_id).toBe(employee2.id);
|
||||
expect(transferred.value.asset_item_id).toBe(assetItem.id);
|
||||
expect(transferred.value.returned_at).toBeNull();
|
||||
|
||||
// The AssetItem must remain "assigned" throughout the transfer — never
|
||||
// flipped to in_stock mid-way (that's the whole point of doing this in
|
||||
// one transaction rather than a separate return + create).
|
||||
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||
expect(item.status).toBe("assigned");
|
||||
|
||||
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
|
||||
expect(original.returnedAt).not.toBeNull();
|
||||
|
||||
const rows = await prisma.assignment.findMany({ where: { assetItemId: assetItem.id } });
|
||||
expect(rows).toHaveLength(2);
|
||||
const newRow = rows.find((row) => row.id !== created.value.id);
|
||||
expect(newRow).toBeDefined();
|
||||
expect(newRow?.employeeId).toBe(employee2.id);
|
||||
expect(newRow?.returnedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("transfers a License assignment to a new employee without changing seatsUsed", async () => {
|
||||
const employee1 = await createTestEmployee({ name: "Employee 1" });
|
||||
const employee2 = await createTestEmployee({ name: "Employee 2" });
|
||||
const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 2 });
|
||||
|
||||
const created = await createAssignment({ employeeId: employee1.id, licenseId });
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
|
||||
let license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
|
||||
expect(license.seatsUsed).toBe(3);
|
||||
|
||||
const transferred = await transferAssignment(created.value.id, { employeeId: employee2.id });
|
||||
expect(transferred.ok).toBe(true);
|
||||
if (!transferred.ok) return;
|
||||
expect(transferred.value.employee_id).toBe(employee2.id);
|
||||
expect(transferred.value.license_id).toBe(licenseId);
|
||||
|
||||
license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
|
||||
expect(license.seatsUsed).toBe(3);
|
||||
|
||||
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
|
||||
expect(original.returnedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects transferring an already-returned assignment", async () => {
|
||||
const employee1 = await createTestEmployee({ name: "Employee 1" });
|
||||
const employee2 = await createTestEmployee({ name: "Employee 2" });
|
||||
const modelId = await createTestModelId();
|
||||
const assetItem = await createTestAssetItem({ modelId });
|
||||
|
||||
const created = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
|
||||
const returned = await returnAssignment(created.value.id, {});
|
||||
expect(returned.ok).toBe(true);
|
||||
|
||||
const transferred = await transferAssignment(created.value.id, { employeeId: employee2.id });
|
||||
expect(transferred.ok).toBe(false);
|
||||
if (transferred.ok) return;
|
||||
expect(transferred.status).toBe(400);
|
||||
expect(transferred.error).toMatch(/already been returned/i);
|
||||
});
|
||||
|
||||
it("returns 404 when transferring a nonexistent assignment", async () => {
|
||||
const employee = await createTestEmployee();
|
||||
const result = await transferAssignment("00000000-0000-0000-0000-000000000000", {
|
||||
employeeId: employee.id,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it("returns a clean 400 when transferring to a nonexistent employeeId", async () => {
|
||||
const employee1 = await createTestEmployee({ name: "Employee 1" });
|
||||
const modelId = await createTestModelId();
|
||||
const assetItem = await createTestAssetItem({ modelId });
|
||||
|
||||
const created = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
|
||||
const transferred = await transferAssignment(created.value.id, {
|
||||
employeeId: "00000000-0000-0000-0000-000000000000",
|
||||
});
|
||||
expect(transferred.ok).toBe(false);
|
||||
if (transferred.ok) return;
|
||||
expect(transferred.status).toBe(400);
|
||||
expect(transferred.error).toMatch(/employeeId/i);
|
||||
expect(transferred.error).toMatch(/does not reference an existing employee/i);
|
||||
|
||||
// Original assignment must not have been closed — the whole transfer
|
||||
// must have rolled back.
|
||||
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
|
||||
expect(original.returnedAt).toBeNull();
|
||||
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||
expect(item.status).toBe("assigned");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTransferAssignmentInput", () => {
|
||||
it("accepts a valid employeeId", () => {
|
||||
const result = validateTransferAssignmentInput({
|
||||
employeeId: "11111111-1111-1111-1111-111111111111",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a missing employeeId", () => {
|
||||
const result = validateTransferAssignmentInput({});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/employeeId/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a non-UUID employeeId", () => {
|
||||
const result = validateTransferAssignmentInput({ employeeId: "not-a-uuid" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/employeeId/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAssignments", () => {
|
||||
it("lists created assignments, newest first", async () => {
|
||||
const employee = await createTestEmployee();
|
||||
@@ -736,6 +881,53 @@ describe("PATCH /api/assignments/[id]", () => {
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
|
||||
it("transfers an assignment to a new employee on valid employeeId input", async () => {
|
||||
const employee1 = await createTestEmployee({ name: "Employee 1" });
|
||||
const employee2 = await createTestEmployee({ name: "Employee 2" });
|
||||
const modelId = await createTestModelId();
|
||||
const assetItem = await createTestAssetItem({ modelId });
|
||||
const createRes = await POST(postRequest({ employeeId: employee1.id, assetItemId: assetItem.id }));
|
||||
const created = await createRes.json();
|
||||
|
||||
const res = await PATCH(patchRequest({ employeeId: employee2.id }), { params: { id: created.id } });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.employee_id).toBe(employee2.id);
|
||||
expect(body.asset_item_id).toBe(assetItem.id);
|
||||
expect(body.returned_at).toBeNull();
|
||||
|
||||
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||
expect(item.status).toBe("assigned");
|
||||
});
|
||||
|
||||
it("rejects a body with both employeeId and returnedAt with a mutual-exclusivity 400", async () => {
|
||||
const employee1 = await createTestEmployee({ name: "Employee 1" });
|
||||
const employee2 = await createTestEmployee({ name: "Employee 2" });
|
||||
const modelId = await createTestModelId();
|
||||
const assetItem = await createTestAssetItem({ modelId });
|
||||
const createRes = await POST(postRequest({ employeeId: employee1.id, assetItemId: assetItem.id }));
|
||||
const created = await createRes.json();
|
||||
|
||||
const res = await PATCH(
|
||||
patchRequest({ employeeId: employee2.id, returnedAt: "2026-08-01" }),
|
||||
{ params: { id: created.id } }
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/either employeeId.*or returnedAt/i);
|
||||
});
|
||||
|
||||
it("returns 401 for a transfer request without a session", async () => {
|
||||
const employee = await createTestEmployee();
|
||||
mockedAuth.mockResolvedValue(null);
|
||||
const res = await PATCH(patchRequest({ employeeId: employee.id }), {
|
||||
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/assignments", () => {
|
||||
|
||||
@@ -115,4 +115,20 @@ describe("verifyUserCredentials", () => {
|
||||
expect(await verifyUserCredentials(email, undefined)).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,6 +125,30 @@ describe("POST /api/employees", () => {
|
||||
});
|
||||
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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -239,6 +239,30 @@ describe("POST /api/requests", () => {
|
||||
const body = await res.json();
|
||||
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", () => {
|
||||
|
||||
+62
-19
@@ -1,18 +1,46 @@
|
||||
// Creates a throwaway Postgres database per test-file run against the
|
||||
// docker-compose `db` service, points DATABASE_URL at it so tests never
|
||||
// touch a shared dev database, and drops it afterwards. Mirrors the old
|
||||
// docker-compose `db` service, and drops it afterwards. Mirrors the old
|
||||
// sqlite temp-file-per-run isolation, adapted to Postgres (CREATE/DROP
|
||||
// DATABASE instead of a throwaway file).
|
||||
//
|
||||
// Creating/dropping the throwaway database itself stays raw `pg` (test
|
||||
// infra, not the app's data-access layer) — but applying the schema to the
|
||||
// fresh database, and tearing down the pooled connection afterwards, now
|
||||
// goes through Prisma instead of the old db.ts.
|
||||
// fresh database goes through `prisma migrate deploy`, and the actual
|
||||
// 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 { execSync } from "node:child_process";
|
||||
import { Client } from "pg";
|
||||
import { afterAll, beforeAll } from "vitest";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { afterAll, vi } from "vitest";
|
||||
|
||||
const baseUrl = new URL(
|
||||
process.env.DATABASE_URL ||
|
||||
@@ -26,6 +54,12 @@ function adminUrl(): string {
|
||||
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> {
|
||||
const client = new Client({ connectionString: adminUrl() });
|
||||
await client.connect();
|
||||
@@ -36,27 +70,36 @@ async function withAdminClient<T>(fn: (client: Client) => Promise<T>): Promise<T
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await withAdminClient((client) => client.query(`CREATE DATABASE ${testDbName}`));
|
||||
// Set by the "@/lib/prisma" mock factory below once it has created this
|
||||
// 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());
|
||||
testUrl.pathname = `/${testDbName}`;
|
||||
process.env.DATABASE_URL = testUrl.toString();
|
||||
vi.mock("@/lib/prisma", async () => {
|
||||
await withAdminClient((client) => client.query(`CREATE DATABASE ${testDbName}`));
|
||||
|
||||
// Apply the Prisma migration history to the fresh throwaway database.
|
||||
// `migrate deploy` replays the same real migrations production uses
|
||||
// (prisma/migrations), rather than a schema-diffing shortcut like
|
||||
// `db push` — keeping this test setup honest about what actually runs
|
||||
// outside tests too.
|
||||
execSync("npx prisma migrate deploy", { stdio: "inherit" });
|
||||
}, 30_000);
|
||||
// outside tests too. Pass the target database explicitly via the
|
||||
// subprocess's own `env` rather than mutating this process's
|
||||
// `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 () => {
|
||||
// Disconnect the app's Prisma Client singleton first so the admin
|
||||
// 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 testPrisma?.$disconnect();
|
||||
await withAdminClient((client) =>
|
||||
client.query(`DROP DATABASE IF EXISTS ${testDbName} WITH (FORCE)`)
|
||||
);
|
||||
|
||||
+17
-13
@@ -9,19 +9,23 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
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"],
|
||||
// Each test file spins up its own throwaway Postgres database by
|
||||
// mutating process.env.DATABASE_URL in tests/setup.ts's beforeAll (see
|
||||
// that file). That per-file DB switch is not safe under Vitest's default
|
||||
// parallel file execution: two files' beforeAll/query timing can
|
||||
// interleave such that both end up issuing real queries against the
|
||||
// same throwaway DB (confirmed empirically once Request gained a real
|
||||
// FK to Employee — tests/employees.test.ts and tests/requests.test.ts
|
||||
// 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,
|
||||
// Each test file gets its own throwaway Postgres database and its own
|
||||
// dedicated PrismaClient, wired up via a `vi.mock("@/lib/prisma", ...)`
|
||||
// factory in tests/setup.ts (not by mutating the ambient
|
||||
// process.env.DATABASE_URL, which used to be shared/racy across files —
|
||||
// see the long comment at the top of tests/setup.ts for the history).
|
||||
// With no mutable state shared between files, parallel file execution
|
||||
// (Vitest's default) is safe again.
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user