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

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 06:41:13 -04:00
toanhoang be47eb69d3 test: add malformed/non-object body tests for assets, employees, requests
Closes the coverage gap noted in CLAUDE.md's "Việc còn dở" — mirrors the
existing pattern in tests/licenses.test.ts and tests/assignments.test.ts for
POST /api/assets, POST /api/employees, POST /api/requests (the only body-
accepting methods on these three routes; none have a PATCH route yet).
2026-08-04 06:40:13 -04:00
toanhoang 2a0acd11a9 fix: run dummy bcrypt.compare on unknown user to prevent timing side-channel 2026-08-04 06:39:08 -04:00
toanhoang 44dd33dd3b docs: note MaintenanceRecord landing + code-review gate PASS in CLAUDE.md
Records the entity build (4 commits), the HIGH race-condition finding
and MEDIUM targetStatus finding from code-reviewer, and their fixes.
2026-08-04 06:36:05 -04:00
toanhoang 7e1351bde2 fix: isolate Prisma Client per test file, re-enable fileParallelism
Root-cause fix for cross-file DB pollution in the vitest suite, which
surfaced as intermittent FK-violation failures once the suite hit 12
test files. tests/setup.ts previously mutated the global
process.env.DATABASE_URL and relied on src/lib/prisma.ts's singleton
picking it up, which is racy across files sharing a worker process
(process.env isn't reset between files even though Vitest resets each
file's module registry). fileParallelism: false only masked this.

Now each test file's own tests/setup.ts creates its own throwaway
Postgres database and vi.mock()s "@/lib/prisma" with a dedicated
PrismaClient constructed via the explicit datasourceUrl option, so no
mutable state is shared between files and fileParallelism can go back
to its default (true).

Verified with 4 consecutive full `npm test` runs (327/327 passing each
time) and no leftover throwaway databases after each run.
2026-08-04 06:04:23 -04:00
toanhoang 91624d5f07 fix: close MaintenanceRecord race condition + restrict targetStatus
createMaintenanceRecord()'s AssetItem status guard pinned status:{not:"retired"}
instead of status:"in_stock", so two concurrent opens (or an open racing a
concurrent createAssignment()) could both pass the guard, producing two open
MaintenanceRecords or an orphaned active Assignment. Guard now pins "in_stock"
exactly, mirroring createAssignment()'s own atomic guard.

closeMaintenanceRecord()'s targetStatus accepted any of the 4 AssetItemStatus
values, including "assigned" — which sets AssetItem.status without creating a
corresponding Assignment row, desyncing Assignment (source of truth for who
holds what) from AssetItem.status with no way back. Restricted to
in_stock|retired, the only two outcomes a closed repair can actually resolve
to.
2026-08-04 06:01:34 -04:00
toanhoang dfbee1a00d test: add MaintenanceRecord API tests 2026-08-04 05:35:37 -04:00
toanhoang 7f4a727669 feat: add MaintenanceRecord API routes
POST/GET /api/maintenance-records and PATCH /api/maintenance-records/[id]
(close action), mirroring the Assignment route pattern exactly.
2026-08-04 05:32:21 -04:00
toanhoang 67fe335e76 feat: add MaintenanceRecord validation + CRUD (src/lib/maintenance-records.ts)
createMaintenanceRecord() opens a record and flips AssetItem.status to
"repair" inside one transaction, rejecting on missing/retired AssetItem,
an active Assignment, or an already-open record for the same item.
closeMaintenanceRecord() sets endDate/resolutionNotes and applies a
caller-supplied targetStatus (never defaulted) to the AssetItem, guarding
double-close and endDate>=startDate. Mirrors src/lib/assignments.ts's
Zod-validate + $transaction + WHERE-guarded updateMany race-closing pattern.
2026-08-04 05:29:26 -04:00
toanhoang 5089bd6dfc feat: add MaintenanceRecord Prisma model + migration
New entity tracking repair/warranty/preventive maintenance jobs for an
AssetItem. Reuses Vendor (nullable) for the repair location, no cost
field (mirrors PurchaseOrder). "At most 1 open record per AssetItem"
invariant left to the application layer, mirroring Assignment.
2026-08-04 05:25:22 -04:00
toanhoang d557694f77 docs: chốt spec MaintenanceRecord (BA + stakeholder 2026-08-04)
Entity mới track sửa chữa/bảo hành cho AssetItem, hiện thực hoá case
"in repair" đã note từ trước. Field, type enum, đồng bộ AssetItem.status,
và quyết định không track cost đều đã chốt với stakeholder — sẵn sàng
giao pm chia task.
2026-08-04 04:18:47 -04:00
toanhoang e1ad9da72d docs: consolidate CLAUDE.md status/backlog, add Assignment review findings
Condensed the "Trạng thái hiện tại"/"Việc còn dở" sections — most of the
detailed per-entity narrative was either resolved, superseded by a later
change, or derivable from git log, so it's trimmed to what isn't:
decisions, gotchas, and still-open risks. Added the UI (worktree-ui-basic
merge), the Assignment transfer fix, and the two dormant findings
(XOR defense-in-depth, Assignment FK onDelete) from the code-reviewer gate.
2026-08-03 20:27:52 -04:00
toanhoang d6cea577c9 fix: scope vitest to tests/ dir to avoid picking up stray worktree checkouts
Vitest's default glob isn't excluded for .claude/, so a sibling worktree
under .claude/worktrees/ (e.g. worktree-ui-basic) got silently collected
and run against this repo's current src/ via the "@" alias, doubling the
test count and running a stale test file against current code under a
shared identity. Found by an independent test-runner pass while verifying
the Assignment transfer fix.
2026-08-03 19:54:49 -04:00
toanhoang a9dafcc140 fix: add atomic Assignment transfer (close old + create new in one transaction)
The only way to move an Assignment to a new employee was two separate calls
(PATCH return + POST create), which is not atomic and left a race window
where a third party could grab the now-in_stock AssetItem in between, plus
no rollback if the second call failed. Adds transferAssignment() which
closes the old Assignment and creates the new one inside a single
prisma.$transaction(), per docs/asset-license-management.md's "Assignment
(chốt 2026-08-02)" spec. Does not touch AssetItem.status or
License.seatsUsed — the resource never becomes free mid-transfer. Wired
into PATCH /api/assignments/[id]: an employeeId key in the body dispatches
to transfer, a returnedAt key to return, both together is rejected as
mutually exclusive.
2026-08-03 19:46:43 -04:00
toanhoang e951a14cc5 feat: add basic list+create+return UI for Assignment 2026-08-03 07:31:32 -04:00
toanhoang 2981ea6202 feat: add basic list+create UI for AssetItem, License, Request 2026-08-03 07:23:25 -04:00
toanhoang e471ae9c90 feat: add basic list+create UI for AssetCategoryAttributeDef, AssetModel, PurchaseOrder 2026-08-03 07:09:42 -04:00
toanhoang 4bcd4da11b feat: add basic list+create UI for Employee, Vendor, AssetCategory 2026-08-03 06:54:41 -04:00
toanhoang 0576d6e61c feat: add shared UI nav + fetch hook + table/select/label/textarea primitives
Basic infra for a functional (no-polish) UI pass over existing entity APIs.
2026-08-03 06:36:16 -04:00
toanhoang 6b39c01f4e test: add Assignment API tests
48 tests covering the XOR validation, the AssetItem.status/active-assignment
preconditions, seat overbooking, seat/status sync across assign+return,
FK-not-found for all three FKs, double-return, 404s, auth-gating, malformed
bodies, and two explicit concurrency tests (Promise.all against a
1-remaining-seat License and against a single in_stock AssetItem) exercising
the transaction race-closing logic in src/lib/assignments.ts.
2026-08-02 21:19:40 -04:00
toanhoang 5145c6af31 feat: add Assignment API routes
POST/GET /api/assignments mirror the existing entity route pattern
(auth-gated, thin handler delegating to src/lib/assignments.ts). PATCH
/api/assignments/[id] is the first dynamic-segment route in the codebase,
per the stakeholder-confirmed convention (docs/asset-license-management.md)
of update routes being PATCH /api/<entity>/[id] rather than an
action-based sub-route.
2026-08-02 21:19:34 -04:00
toanhoang a72b8dbbbe feat: add Assignment validation + CRUD (src/lib/assignments.ts)
createAssignment() enforces the XOR (assetItemId/licenseId) at the Zod
layer and the AssetItem.status/License.seatsUsed business rules inside a
single prisma.$transaction(), using atomic WHERE-guarded updateMany() calls
(not plain read-then-write) to close the seat-overbooking and
double-assignment race conditions flagged for License by code-reviewer.
returnAssignment() reverses those side-effects the same way and closes the
double-return race with the same pattern.
2026-08-02 21:19:28 -04:00
toanhoang 07b01cd707 feat: add Assignment Prisma model + migration
Links Employee to exactly one of AssetItem/License (XOR enforced at the
Zod layer, not built yet) via nullable FKs, plus assignedAt/returnedAt/
signedAt timestamps. See docs/asset-license-management.md's "Assignment"
section for the full spec.
2026-08-02 21:13:46 -04:00
toanhoangandClaude Sonnet 5 2037e6939e Merge branch 'feat/asset-item-model-id'
Retrofit AssetItem.model (string) -> modelId (FK to AssetModel) —
closes out Lane B (AssetCategory -> AssetCategoryAttributeDef +
AssetModel -> dynamic specs validation -> AssetItem.modelId).
Verified green by test-runner (225/225 tests, tsc/lint/build clean)
before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 21:11:13 -04:00
toanhoang 2a5792df67 fix: retrofit AssetItem.model to modelId FK
AssetItem.model was a free-text string with no referential integrity.
Now a required FK to AssetModel, mirroring the Request.employeeId and
License.vendorId retrofit precedent (no backfill needed, no real
AssetItem data existed).
2026-08-02 21:07:12 -04:00
toanhoangandClaude Sonnet 5 d280279322 Merge branch 'feat/asset-model-dynamic-specs'
Wire dynamic specs validation into AssetModel creation: builds a
runtime Zod schema from the category's AssetCategoryAttributeDef
rows, rejects unknown keys, enforces required keys. Verified green
by test-runner (223/223 tests, tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 21:02:21 -04:00
toanhoang dc4c438ca9 feat: wire dynamic specs validation into AssetModel creation 2026-08-02 20:58:19 -04:00
toanhoangandClaude Sonnet 5 bcd41e4ba3 Merge branch 'feat/asset-model'
AssetModel entity: Prisma model+migration, Zod+CRUD (categoryId FK
+ business rule requiring valueGroup=high_value, specs JSONB loosely
validated for now), API routes, tests. Verified green by test-runner
(217/217 tests, tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 20:54:06 -04:00
toanhoang 0fdf82be61 test: add AssetModel API tests 2026-08-02 20:49:24 -04:00
toanhoang c9b595118a feat: add AssetModel API routes 2026-08-02 20:48:09 -04:00
toanhoang 1c530a3162 feat: add AssetModel validation + CRUD (src/lib/asset-models.ts) 2026-08-02 20:48:09 -04:00
toanhoang c02235f381 feat: add AssetModel Prisma model + migration 2026-08-02 20:46:48 -04:00
toanhoangandClaude Sonnet 5 d3536b60ad Merge branch 'feat/asset-category-attribute-def'
AssetCategoryAttributeDef entity: Prisma model+migration (dataType
enum, composite unique (categoryId,key)), Zod+CRUD (P2002+P2003),
API routes, tests. Verified green by test-runner (192/192 tests,
tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 20:44:32 -04:00
toanhoang b91a1d2185 test: add AssetCategoryAttributeDef API tests
28 tests covering lib-level validation/CRUD and route-level HTTP behavior:
malformed JSON, non-object body, invalid dataType, categoryId FK violation,
duplicate (categoryId, key) rejected with a clean 400 (not 500), and same
key allowed across two different categories (the "not globally unique"
case), plus auth gating.
2026-08-02 20:40:17 -04:00
toanhoang 59425a159b feat: add AssetCategoryAttributeDef API routes
POST/GET /api/asset-category-attribute-defs, auth-gated, thin route mirroring
src/app/api/asset-categories/route.ts.
2026-08-02 20:38:46 -04:00
toanhoang 123cbce2bc feat: add AssetCategoryAttributeDef validation + CRUD (src/lib/asset-category-attribute-defs.ts)
Zod schema + validate function, Prisma-backed CRUD following the
{ok,value}|{ok,error} pattern from the start. createAssetCategoryAttributeDef()
catches P2002 for the composite (categoryId, key) unique constraint (mirrors
createAssetItem()'s serialNumber handling) and P2003 for the categoryId FK
(mirrors createPurchaseOrder()).
2026-08-02 20:38:41 -04:00
toanhoang f7b9566b04 feat: add AssetCategoryAttributeDef Prisma model + migration
Per-category dynamic attribute definitions (key/label/dataType/required/
sortOrder), FK to AssetCategory, composite unique (categoryId, key) so the
same key is allowed across different categories but not duplicated within
one. dataType is a new closed Prisma enum (AssetAttributeDataType), a
dev-defined technical set distinct from AssetCategoryValueGroup.
2026-08-02 20:38:06 -04:00
toanhoangandClaude Sonnet 5 065c8cb59e Merge branch 'feat/asset-category'
AssetCategory entity: Prisma model+migration (valueGroup enum
high_value/low_value), Zod+CRUD, API routes, tests. Verified green by
test-runner (164/164 tests, tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 20:36:20 -04:00
toanhoang aaab8fd9b7 test: add AssetCategory API tests 2026-08-02 20:32:38 -04:00
toanhoang 63eb3df33d feat: add AssetCategory API routes 2026-08-02 20:31:27 -04:00
toanhoang 20ec7a11df feat: add AssetCategory validation + CRUD (src/lib/asset-categories.ts) 2026-08-02 20:31:22 -04:00
toanhoang 69fa00012b feat: add AssetCategory Prisma model + migration 2026-08-02 20:30:38 -04:00
toanhoangandClaude Sonnet 5 b8b1709a31 Merge branch 'feat/request-po-id'
Add nullable Request.purchaseOrderId FK (demand-side PO
traceability) — closes out Lane A (Vendor -> License.vendorId ->
PurchaseOrder -> purchaseOrderId x3). Verified green by test-runner
(143/143 tests, tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 20:28:50 -04:00
toanhoang 65232fc62c feat: add Request.purchaseOrderId (nullable FK) 2026-08-02 20:24:59 -04:00
toanhoangandClaude Sonnet 5 3dd1b22828 Merge branch 'feat/license-po-id'
Add nullable License.purchaseOrderId FK (supply-side PO
traceability). Verified green by test-runner (139/139 tests,
tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 20:20:58 -04:00
toanhoang abec58d239 feat: add License.purchaseOrderId (nullable FK)
Nullable FK from License to PurchaseOrder for supply-side traceability
(which PO a license came in through), mirroring AssetItem.purchaseOrderId's
just-merged pattern exactly. Separate commit from the earlier
License.vendorId retrofit per the project's 1-commit-per-business-change
convention.
2026-08-02 20:17:09 -04:00
toanhoangandClaude Sonnet 5 fb52f473a5 Merge branch 'feat/asset-item-po-id'
Add nullable AssetItem.purchaseOrderId FK (supply-side PO
traceability). Verified green by test-runner (131/131 tests,
tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 20:12:50 -04:00
toanhoang a6d7c7b302 feat: add AssetItem.purchaseOrderId (nullable FK) 2026-08-02 20:09:06 -04:00
toanhoangandClaude Sonnet 5 4d12885500 Merge branch 'feat/purchase-order'
PurchaseOrder entity: Prisma model+migration, Zod+CRUD (P2003 on both
vendorId/receivedById, receivedAt/receivedById co-nullability
refine), API routes, tests. Verified green by test-runner (127/127
tests, tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 20:04:58 -04:00
toanhoang 2b3c39a291 test: add PurchaseOrder API tests 2026-08-02 20:00:48 -04:00
toanhoang ad4b5d7c2f feat: add PurchaseOrder API routes 2026-08-02 19:59:18 -04:00
toanhoang cc98ca4d51 feat: add PurchaseOrder validation + CRUD (src/lib/purchase-orders.ts) 2026-08-02 19:59:05 -04:00
toanhoang 58235112f0 feat: add PurchaseOrder Prisma model + migration 2026-08-02 19:57:58 -04:00
toanhoangandClaude Sonnet 5 7b5ab70596 Merge branch 'feat/license-vendor-id'
Retrofit License.vendor (string) -> vendorId (FK to Vendor),
createLicense() now returns {ok,error} pattern. Verified green by
test-runner (97/97 tests, tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 19:55:35 -04:00
toanhoang 9d99ebb425 fix: retrofit License.vendor to vendorId FK + createLicense error handling
License.vendor (free-text string) is now vendorId, a required FK to
Vendor, per docs/asset-license-management.md's "## Vendor (chốt
2026-08-02)" spec. Mirrors the Request.employeeId string->FK precedent:
Zod validates UUID format only, Prisma's own FK constraint (P2003) is
caught at insert time and translated into a clean 400 instead of a raw
500. No License data existed yet, so this is a pure column-type
migration, no backfill needed.

Also converts createLicense() to the {ok,value}|{ok,error} result
pattern used elsewhere (createRequest, createAssetItem) instead of
returning a bare LicenseRecord promise - closes the gap flagged in
CLAUDE.md now that License has its first FK-violation error case to
handle.
2026-08-02 19:41:33 -04:00
toanhoangandClaude Sonnet 5 9a4a3fede3 Merge branch 'feat/vendor'
Vendor entity: Prisma model+migration, Zod+CRUD, API routes, tests.
Verified green by test-runner (94/94 tests, tsc/lint/build clean)
before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 19:36:49 -04:00
toanhoang f613763ba4 test: add Vendor API tests 2026-08-02 19:29:48 -04:00
toanhoang 283d94f07a feat: add Vendor API routes 2026-08-02 19:28:36 -04:00
toanhoang 19b652142a feat: add Vendor validation + CRUD (src/lib/vendors.ts) 2026-08-02 19:28:25 -04:00
toanhoang 07ef1582d3 feat: add Vendor Prisma model + migration 2026-08-02 19:28:09 -04:00
toanhoangandClaude Sonnet 5 f325abf22c docs: resolve Assignment open questions, unblock Lane C
Stakeholder decided: createAssignment() blocks if AssetItem.status
isn't in_stock, and update routes use PATCH /api/<entity>/[id]
(applies to both Assignment and PurchaseOrder).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 19:22:43 -04:00
toanhoang e3a3780253 Merge branch 'worktree-vendor-po-assignment-spec'
# Conflicts:
#	docs/asset-license-management.md
2026-08-02 19:20:34 -04:00
toanhoangandClaude Sonnet 5 4a0d00afd8 docs: spec Vendor/PurchaseOrder/Assignment + attribute defs, sequence tasks
BA formalizes the Vendor, PurchaseOrder (absorbs Receipt), Assignment,
and AssetCategoryAttributeDef designs decided in stakeholder brainstorm
into docs/asset-license-management.md. PM sequences the resulting work
into dependency-ordered, commit-mapped tasks across 3 independent lanes
in CLAUDE.md, surfacing that AssetCategory/AssetModel/ConsumableItem
were spec'd earlier but never actually built, and flagging two open
questions (Assignment precondition on AssetItem.status; REST shape for
update operations) that need stakeholder input before Lane C starts.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 13:12:27 -04:00
toanhoangandClaude Sonnet 5 29febf8205 docs: add asset high-value/low-value classification spec
AssetCategory/AssetModel hierarchy, ConsumableItem for low-value/
consumable items, data constraints, and open questions for the
consumable request flow — decisions already confirmed with
stakeholder, spec was written but not yet committed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 13:01:02 -04:00
72 changed files with 11917 additions and 236 deletions
+27 -34
View File
@@ -80,43 +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".
- Các entity còn lại: `PurchaseOrder`, `Receipt`, `Assignment` (`AssetItem` xong 2026-07-31, `License` xong 2026-08-02, xem "Trạng thái hiện tại"). `Assignment` là entity tiếp theo nên làm — nối `Employee`+`AssetItem`+`License`, trả lời trực tiếp câu hỏi nghiệp vụ "ai đang giữ máy/license nào" nêu ở `docs/asset-license-management.md`.
- Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled
- 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)
Cập nhật lần cuối: 2026-08-04 (UI cho `MaintenanceRecord` xong — list+create+close; UI transfer cho `Assignment` xong)
+92 -11
View File
@@ -23,17 +23,18 @@ Theo dõi toàn bộ vòng đời tài sản/license: **Request → Approval →
| Entity | Ý nghĩa |
|---|---|
| `Employee` | id, tên, phòng ban, manager |
| `Vendor` | nhà cung cấp |
| `AssetItem` | hardware **high-value** (serial hóa bắt buộc) — serial number, model (FK → `AssetModel`, bắt buộc — không còn string tự do), ngày mua, bảo hành, trạng thái (in stock / assigned / repair / retired). Category không còn gán trực tiếp trên `AssetItem` — suy ra qua `AssetModel.categoryId`. Xem "Phân loại tài sản" bên dưới |
| `License` | phần mềm — tên, vendor, tổng seat, seat đã dùng, ngày hết hạn, loại (perpetual/subscription) |
| `Request` | người yêu cầu, loại (asset/license), lý do, trạng thái — **chỉ áp dụng cho asset high-value/license**, không dùng cho consumable (xem "Phân loại tài sản" bên dưới) |
| `PurchaseOrder` | gộp nhiều Request đã duyệt, vendor, ngày đặt, ngày dự kiến nhận |
| `Receipt` | nhận hàng — đối chiếu PO, ngày nhận, người nhận |
| `Assignment` | gán AssetItem (high-value)/License cho 1 Employee cụ thể — ngày giao, ngày trả, biên bản ký nhận. **Không áp dụng cho consumable** (consumable track theo tồn kho, không gắn 1 người cụ thể) |
| `Vendor` | id, `name`, `contactEmail`, `contactPhone`, `address`, `taxCode` (nullable — mã số thuế VN), `notes` (nullable) — nhà cung cấp/reseller mua hàng qua, **khác** `AssetModel.manufacturer` (hãng sản xuất/OEM). Xem "Vendor" bên dưới |
| `AssetItem` | hardware **high-value** (serial hóa bắt buộc) — serial number, model (FK → `AssetModel`, bắt buộc — không còn string tự do), ngày mua, bảo hành, trạng thái (in stock / assigned / repair / retired), `purchaseOrderId` (FK → `PurchaseOrder`, nullable — chiều cung, xem "PurchaseOrder" bên dưới). Category không còn gán trực tiếp trên `AssetItem` — suy ra qua `AssetModel.categoryId`. Xem "Phân loại tài sản" bên dưới |
| `License` | phần mềm — tên, `vendorId` (FK → `Vendor`, retrofit từ string tự do — xem "Vendor" bên dưới), tổng seat, seat đã dùng, ngày hết hạn, loại (perpetual/subscription), `purchaseOrderId` (FK → `PurchaseOrder`, nullable — chiều cung, xem "PurchaseOrder" bên dưới) |
| `Request` | người yêu cầu, loại (asset/license), lý do, trạng thái, `purchaseOrderId` (FK → `PurchaseOrder`, nullable — chiều cầu, xem "PurchaseOrder" bên dưới) — **chỉ áp dụng cho asset high-value/license**, không dùng cho consumable (xem "Phân loại tài sản" bên dưới) |
| `PurchaseOrder` | id, `vendorId` (FK → `Vendor`), `orderDate`, `expectedDate`, `receivedAt` (nullable — null nghĩa là chưa nhận), `receivedBy` (FK → `Employee`, nullable, set cùng lúc với `receivedAt`). **Gộp luôn `Receipt`** (không còn là entity riêng — nhận hàng là 1 phần trạng thái của chính PO), all-or-nothing theo cả PO (không nhận từng phần), không track giá/chi phí, không có `status` riêng (suy ra từ Request liên kết). Xem "PurchaseOrder" bên dưới |
| `Assignment` | id, `employeeId` (FK → `Employee`), `assetItemId` (FK → `AssetItem`, nullable) / `licenseId` (FK → `License`, nullable — đúng 1 trong 2, XOR), `assignedAt`, `returnedAt` (nullable — null nghĩa đang giữ), `signedAt` (nullable — chỉ là timestamp đánh dấu đã ký, không upload file). Gán AssetItem (high-value)/License cho 1 Employee cụ thể. **Không áp dụng cho consumable** (consumable track theo tồn kho, không gắn 1 người cụ thể). Xem "Assignment" bên dưới |
| `Request.servicenow_ticket` | field text, nhập tay số ticket ServiceNow liên quan (không tích hợp API ServiceNow ngay — volume thấp, nhập tay đủ dùng; để ngỏ cho auto-sync sau nếu cần) |
| `AssetCategory` | id, tên (vd "Laptop", "Cáp HDMI"), nhóm giá trị cố định (`high_value` \| `low_value`) — danh mục category cụ thể do IT admin tự định nghĩa, không hardcode. Xem "Phân loại tài sản" bên dưới |
| `AssetModel` | id, category (FK → `AssetCategory`, bắt buộc, phải thuộc nhóm `high_value`), `manufacturer` (vd "Dell"), `name` (vd "Latitude 5420") — tách riêng 2 field, không gộp chung 1 string, để filter/report theo hãng. Nằm giữa `AssetCategory` và `AssetItem` (1 model có nhiều `AssetItem` serial khác nhau). Xem "Phân loại tài sản" bên dưới |
| `AssetModel` | id, category (FK → `AssetCategory`, bắt buộc, phải thuộc nhóm `high_value`), `manufacturer` (vd "Dell"), `name` (vd "Latitude 5420"), `specs` (JSONB — giá trị thật của các thuộc tính động, xem `AssetCategoryAttributeDef`) — tách riêng 2 field `manufacturer`/`name`, không gộp chung 1 string, để filter/report theo hãng. Nằm giữa `AssetCategory` và `AssetItem` (1 model có nhiều `AssetItem` serial khác nhau). Xem "Phân loại tài sản" bên dưới |
| `AssetCategoryAttributeDef` | id, `categoryId` (FK → `AssetCategory`), `key` (slug, vd `ram_gb`), `label` (tên hiển thị, vd "RAM (GB)"), `dataType` (enum đóng: `string`/`number`/`boolean`/`date`), `required` (bool), `sortOrder` (int) — định nghĩa thuộc tính riêng theo từng category, giá trị thật nằm ở `AssetModel.specs`. Unique `(categoryId, key)`. Xem "Phân loại tài sản" bên dưới |
| `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
@@ -52,14 +53,93 @@ Draft → Pending Approval → Approved → Ordered → Received → Delivered
**Cập nhật 2026-08-02 (2): thêm phân cấp `AssetModel` giữa `AssetCategory` và `AssetItem`.** Lý do: nhiều model khác nhau trong cùng 1 category (vd category "Laptop" có nhiều model "Dell Latitude 5420", "MacBook Pro 14"...), cần report/thống kê theo model (vd "đang có bao nhiêu Dell Latitude 5420"), tránh lỗi gõ tay khi model là string tự do như trước. Phân cấp: `AssetCategory` ("Laptop") → `AssetModel` ("Dell Latitude 5420") → từng `AssetItem` (1 serial cụ thể thuộc model đó). `AssetModel.manufacturer`/`AssetModel.name` tách riêng (không gộp 1 string) để filter/report riêng theo hãng. Khái niệm "model" chỉ có ý nghĩa cho asset serial hóa (`high_value`) — `ConsumableItem` không có model, vẫn trỏ thẳng `AssetCategory` (`low_value`) như cũ.
**Cập nhật 2026-08-02 (3): thêm `AssetCategoryAttributeDef` — thuộc tính đặc thù theo từng category.** Lý do: từng category có bộ thuộc tính riêng cần track (vd RAM/CPU cho category "Laptop", độ phân giải cho "Màn hình") mà không phải asset high-value nào cũng giống nhau — cần định nghĩa động theo category, không hardcode cột cứng cho từng loại thuộc tính.
- Entity mới `AssetCategoryAttributeDef`: `categoryId` (FK → `AssetCategory`), `key` (slug, vd `ram_gb`), `label` (tên hiển thị, vd "RAM (GB)"), `dataType` (enum đóng: `string`/`number`/`boolean`/`date` — đây là tập giá trị kỹ thuật cố định do dev định nghĩa, **khác** với `AssetCategory` là tập mở IT admin tự mở rộng), `required` (bool), `sortOrder` (int, thứ tự hiển thị trên form). Unique `(categoryId, key)`.
- Mỗi `AssetCategory` có bộ attribute **riêng, độc lập** — cố tình **không** phải thư viện thuộc tính dùng chung giữa các category. Nếu 2 category cùng muốn 1 thuộc tính kiểu "khối lượng (kg)", mỗi category tự tạo 1 row `AssetCategoryAttributeDef` riêng — trùng tên `key` giữa 2 category là bình thường và được chấp nhận, đây là 2 row không liên quan tới nhau. Lý do: ở quy mô hiện tại (ít category), thiết kế thư viện thuộc tính dùng chung (tách riêng bảng `AttributeDef` + bảng join `CategoryAttribute`) cộng thêm độ phức tạp thật (thêm 1 bảng, UX phải chọn "gắn thuộc tính có sẵn hay tạo mới", sửa 1 def ảnh hưởng dây chuyền nhiều category) để đổi lấy lợi ích (sửa 1 chỗ áp dụng khắp nơi, query cross-category an toàn) mà hiện chưa cần tới — stakeholder đã từ chối rõ ràng thiết kế dùng chung để giữ đơn giản.
- Giá trị thật nằm ở `AssetModel.specs` (cột JSONB) — **không** nằm trên `AssetItem`, vì đây là thuộc tính theo spec-sheet dùng chung cho mọi serial thuộc cùng 1 model (mọi `AssetItem` "Dell Latitude 5420" đều cùng RAM), không phải dữ liệu riêng theo từng instance. Validate động lúc ghi: app build Zod schema runtime từ các `AssetCategoryAttributeDef` thuộc category của model đó (map `dataType` → `z.string()`/`z.number()`/`z.boolean()`/`z.coerce.date()`), từ chối key lạ chưa được định nghĩa cho category, bắt buộc các key có `required = true` phải có mặt.
- Known gap chấp nhận được, chưa chặn: nếu admin sửa/xoá 1 `AssetCategoryAttributeDef` sau khi đã có `AssetModel` mang giá trị dưới key đó trong `specs`, giá trị cũ trở thành orphaned/stale — chưa có cơ chế migration/cleanup. Ghi nhận là known gap để xử lý khi có UI quản lý attribute-def thật, không cần giải quyết ngay bây giờ.
**Ràng buộc dữ liệu:**
- `AssetModel.categoryId` bắt buộc, phải trỏ tới 1 `AssetCategory` có `valueGroup = high_value`.
- `AssetItem.modelId` bắt buộc, phải trỏ tới 1 `AssetModel` có thật (thay cho field `model` string tự do trước đây — đã đổi, không cần chiến lược backfill vì DB chưa có `AssetItem` thật). `AssetItem` không còn giữ `categoryId` trực tiếp — category được suy ra qua `AssetItem.modelId → AssetModel.categoryId` (tránh 2 nguồn sự thật có thể lệch nhau).
- `ConsumableItem.categoryId` bắt buộc, phải trỏ tới 1 `AssetCategory` có `valueGroup = low_value` — trỏ thẳng, không qua `AssetModel`.
- `Request` (state machine đầy đủ) tiếp tục chỉ áp dụng cho `type: asset|license` như hiện tại — **không** mở rộng `RequestType` thêm `consumable`.
- `AssetCategoryAttributeDef.categoryId` bắt buộc, phải trỏ tới 1 `AssetCategory` có thật. Unique `(categoryId, key)`.
- `AssetModel.specs` (JSONB) chỉ được chứa các key đã có `AssetCategoryAttributeDef` tương ứng với category của model đó — validate ở tầng Zod (build động lúc runtime), không phải DB CHECK constraint (nhất quán với nguyên tắc "không raw SQL cho app logic" của codebase).
**Luồng riêng cho consumable ("xin + nhận" đơn giản hoá):** stakeholder đã xác nhận muốn 1 luồng gọn hơn state machine đầy đủ của `Request`, nhưng **chưa chốt chi tiết state/approval/xử lý hết hàng** — xem danh sách câu hỏi mở trong spec BA (2026-08-02, lưu ở lịch sử trao đổi/PR liên quan). Không build luồng này (entity `ConsumableRequest` hoặc tương đương) tới khi có câu trả lời — chỉ mới build phần catalog (`AssetCategory`, `ConsumableItem`).
## Vendor (chốt 2026-08-02)
Entity mới, độc lập với `AssetModel`.
- Field: `id`, `name`, `contactEmail`, `contactPhone`, `address`, `taxCode` (nullable — mã số thuế VN), `notes` (nullable).
- **Phân biệt quan trọng:** `Vendor` (nơi mua hàng — vd nhà phân phối/reseller VN) là khái niệm **khác** `AssetModel.manufacturer` (hãng sản xuất/OEM, vd "Dell") — không phải lúc nào cũng cùng 1 công ty (vd mua laptop Dell qua reseller "FPT Trading", không mua thẳng từ Dell). Quyết định: giữ nguyên `AssetModel.manufacturer` là string tự do như hiện tại (không đổi gì); `Vendor` chỉ dùng cho quan hệ mua sắm (`PurchaseOrder`, `License`).
- **Retrofit `License.vendor`** (hiện là string tự do) → **`License.vendorId`** (FK → `Vendor`), theo đúng tiền lệ đã có trong codebase (`Request.employeeId` string→FK khi `Employee` ra đời, `AssetItem.model` string→FK khi `AssetModel` ra đời — xem "Trạng thái hiện tại" ở `../CLAUDE.md`). Chưa có `License` thật nào trong DB production nên đây là đổi kiểu cột thuần tuý, **không cần chiến lược backfill** — giống các retrofit trước đó.
**Ràng buộc dữ liệu:**
- `License.vendorId` bắt buộc, phải trỏ tới 1 `Vendor` có thật (thay cho field `vendor` string tự do trước đây).
## PurchaseOrder (gộp Receipt) (chốt 2026-08-02)
**Quyết định: gộp `Receipt` vào `PurchaseOrder`, không giữ là entity riêng nữa** — nhận hàng chỉ là 1 phần trạng thái của chính PO đó (`receivedAt`/`receivedBy`), không cần 1 bảng riêng để đối chiếu. Row `Receipt` trong bảng Data model ở trên đã được xoá, gộp vào row `PurchaseOrder`.
- Field: `id`, `vendorId` (FK → `Vendor`), `orderDate`, `expectedDate`, `receivedAt` (nullable DateTime — null nghĩa là chưa nhận), `receivedBy` (nullable FK → `Employee`, set cùng lúc với `receivedAt`).
- Cố tình **không** có field giá/chi phí — stakeholder nói rõ `PurchaseOrder` không cần track tiền, chỉ cần trả lời "asset/license nào thuộc PO nào".
- Cố tình **không** có field `PurchaseOrder.status` riêng — trạng thái suy ra từ trạng thái của các `Request` liên kết (`Request` đã có state machine `Ordered → Received → Delivered` riêng), tránh 2 nguồn sự thật.
- **Nhận hàng là all-or-nothing theo từng PO** ("nhận 1 lần đủ cho cả PO") — không hỗ trợ nhận nhiều đợt/từng phần. Đây là đánh đổi đơn giản hoá **có chủ ý**, stakeholder đã biết rõ và chấp nhận có thể cần đổi lại sau (paraphrase: "giờ làm đơn giản vậy trước, sau này cần đổi thì tính tiếp") — ghi lại rõ đây là tradeoff/assumption đã note, không phải bỏ sót khi viết spec.
- **Traceability** (đúng yêu cầu cốt lõi của stakeholder: "chỉ cần biết asset nằm trong PO nào"): thêm `purchaseOrderId` (nullable FK → `PurchaseOrder`) ở **CẢ BA** chỗ — `AssetItem`, `License`, và `Request`. Ba field này phục vụ mục đích khác nhau, không dư thừa:
- `Request.purchaseOrderId` — chiều cầu (demand-side): request nào đã duyệt được gộp vào PO này (đây vốn là mục đích ban đầu đã note trong doc cho `PurchaseOrder`: "gộp nhiều Request đã duyệt").
- `AssetItem.purchaseOrderId` và `License.purchaseOrderId` — chiều cung (supply-side): record tồn kho/thực tế nào tới qua PO này. Set lúc intake/tạo mới. Nullable vì không phải `AssetItem`/`License` nào cũng đi qua 1 PO chính thức (vd tồn kho lịch sử nhập trước khi có hệ thống).
**Ràng buộc dữ liệu:**
- `PurchaseOrder.vendorId` bắt buộc, trỏ tới 1 `Vendor` có thật.
- `PurchaseOrder.receivedBy` chỉ set cùng lúc với `receivedAt` (cả hai cùng null hoặc cùng có giá trị).
- `AssetItem.purchaseOrderId`, `License.purchaseOrderId`, `Request.purchaseOrderId` đều nullable, trỏ tới `PurchaseOrder` nếu có.
- `PurchaseOrder` không có field `status` — suy ra qua `Request` liên kết, không lưu trùng.
## Assignment (chốt 2026-08-02)
- Field: `id`, `employeeId` (FK → `Employee`), `assetItemId` (FK → `AssetItem`, nullable), `licenseId` (FK → `License`, nullable), `assignedAt`, `returnedAt` (nullable — null nghĩa đang giữ), `signedAt` (nullable DateTime — null nghĩa biên bản chưa ký. Cố tình chỉ là 1 timestamp đánh dấu đã ký/chưa, **KHÔNG** phải upload file — stakeholder đã quyết định rõ không cần lưu file scan lúc này, "biên bản ký nhận" chỉ cần đánh dấu đã ký + ngày ký).
- Đúng 1 trong 2 (`assetItemId`/`licenseId`) phải được set — không cả hai, không cả hai đều rỗng (XOR) — validate ở tầng Zod qua `.refine()`, theo đúng cách codebase đã xử lý validate cross-field (không phải DB CHECK constraint, đúng nguyên tắc "không raw SQL cho app logic" — Prisma schema cũng không diễn tả được constraint XOR này natively).
- Không phải entity mới, nhưng có các business rule tầng application cần note lại rõ ở đây (đủ quan trọng để phát sinh bug nếu bỏ sót, nhưng là rule app phải tự enforce, không phải bản thân là 1 entity):
- 1 `AssetItem` chỉ có tối đa 1 `Assignment` **active** (`returnedAt IS NULL`) tại 1 thời điểm — không thể bị giữ bởi 2 người cùng lúc. Enforce trước khi tạo `Assignment` mới.
- Assign 1 `License` phải **tăng** `License.seatsUsed`, và phải **bị từ chối** nếu `seatsUsed >= seatsTotal` (không overbook). Trả lại (set `returnedAt`) **giảm** `seatsUsed`. Toàn bộ phải chạy trong 1 transaction — đây chính là rủi ro seat-count race condition mà `code-reviewer` đã note trước đó cho `License` (xem "Trạng thái hiện tại" ở `../CLAUDE.md`, mục `License`) — `Assignment` là chỗ thực sự implement/exercise seat counting lần đầu tiên.
- Chuyển tài sản giữa nhân viên (case đặc biệt đã note ở trên: "Tài sản luân chuyển giữa phòng ban / chi nhánh") = đóng `Assignment` cũ (`returnedAt = now`) + tạo `Assignment` mới, cả hai trong 1 transaction. Không cần entity "Transfer" riêng.
- `AssetItem.status` phải đồng bộ theo trạng thái `Assignment`: tạo `Assignment` → status `assigned`; trả lại → status `in_stock`, trừ nhánh RMA/sửa chữa đã note ở "Case đặc biệt cần xử lý" (đó là case riêng, không đi qua path trả-lại-thông-thường này).
- **Chốt 2026-08-02 (trả lời 2 câu hỏi mở của `pm`):** (a) `createAssignment()` phải **chặn** nếu `AssetItem.status` hiện tại khác `in_stock` (vd không cho assign 1 item đang `repair`/`retired`) — trả lỗi 400, không chỉ dựa vào điều kiện "chưa có Assignment active nào". (b) Route "update" (trả lại/chuyển giao `Assignment`, nhận hàng `PurchaseOrder`) dùng `PATCH /api/<entity>/[id]` — tiền lệ REST-shape đầu tiên cho update trong codebase, áp dụng thống nhất cho cả `Assignment` và `PurchaseOrder`, không dùng action-based sub-route.
**Ràng buộc dữ liệu:**
- `Assignment.employeeId` bắt buộc, trỏ tới 1 `Employee` có thật.
- Đúng 1 trong `assetItemId`/`licenseId` được set (XOR) — validate Zod `.refine()`.
- Không được có > 1 `Assignment` active cho cùng 1 `AssetItem` tại 1 thời điểm (enforce ở tầng application, không phải DB constraint).
- 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).
@@ -85,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
@@ -113,9 +193,10 @@ QR gán asset vào slot (bước 1, thủ công hàng ngày) **không phải IoT
## Việc còn dở
- Thiết kế ERD chi tiết + wireframe màn hình
- Entity còn lại: `PurchaseOrder`, `Receipt`, `Assignment` (`Employee`, `AssetItem` đã xong 2026-07-31; `License` xong 2026-08-02, xem `../CLAUDE.md`)
- **`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.
@@ -124,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
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,13 @@
-- CreateTable
CREATE TABLE "vendors" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"contact_email" TEXT NOT NULL,
"contact_phone" TEXT NOT NULL,
"address" TEXT NOT NULL,
"tax_code" TEXT,
"notes" TEXT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "vendors_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `vendor` on the `licenses` table. All the data in the column will be lost.
- Added the required column `vendor_id` to the `licenses` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "licenses" DROP COLUMN "vendor",
ADD COLUMN "vendor_id" UUID NOT NULL;
-- AddForeignKey
ALTER TABLE "licenses" ADD CONSTRAINT "licenses_vendor_id_fkey" FOREIGN KEY ("vendor_id") REFERENCES "vendors"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "purchase_orders" (
"id" UUID NOT NULL,
"vendor_id" UUID NOT NULL,
"order_date" TIMESTAMPTZ(6) NOT NULL,
"expected_date" TIMESTAMPTZ(6) NOT NULL,
"received_at" TIMESTAMPTZ(6),
"received_by" UUID,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "purchase_orders_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "purchase_orders" ADD CONSTRAINT "purchase_orders_vendor_id_fkey" FOREIGN KEY ("vendor_id") REFERENCES "vendors"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "purchase_orders" ADD CONSTRAINT "purchase_orders_received_by_fkey" FOREIGN KEY ("received_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "asset_items" ADD COLUMN "purchase_order_id" UUID;
-- AddForeignKey
ALTER TABLE "asset_items" ADD CONSTRAINT "asset_items_purchase_order_id_fkey" FOREIGN KEY ("purchase_order_id") REFERENCES "purchase_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "licenses" ADD COLUMN "purchase_order_id" UUID;
-- AddForeignKey
ALTER TABLE "licenses" ADD CONSTRAINT "licenses_purchase_order_id_fkey" FOREIGN KEY ("purchase_order_id") REFERENCES "purchase_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "requests" ADD COLUMN "purchase_order_id" UUID;
-- AddForeignKey
ALTER TABLE "requests" ADD CONSTRAINT "requests_purchase_order_id_fkey" FOREIGN KEY ("purchase_order_id") REFERENCES "purchase_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- CreateEnum
CREATE TYPE "AssetCategoryValueGroup" AS ENUM ('high_value', 'low_value');
-- CreateTable
CREATE TABLE "asset_categories" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"value_group" "AssetCategoryValueGroup" NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "asset_categories_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,22 @@
-- CreateEnum
CREATE TYPE "AssetAttributeDataType" AS ENUM ('string', 'number', 'boolean', 'date');
-- CreateTable
CREATE TABLE "asset_category_attribute_defs" (
"id" UUID NOT NULL,
"category_id" UUID NOT NULL,
"key" TEXT NOT NULL,
"label" TEXT NOT NULL,
"data_type" "AssetAttributeDataType" NOT NULL,
"required" BOOLEAN NOT NULL,
"sort_order" INTEGER NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "asset_category_attribute_defs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "asset_category_attribute_defs_category_id_key_key" ON "asset_category_attribute_defs"("category_id", "key");
-- AddForeignKey
ALTER TABLE "asset_category_attribute_defs" ADD CONSTRAINT "asset_category_attribute_defs_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "asset_categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "asset_models" (
"id" UUID NOT NULL,
"category_id" UUID NOT NULL,
"manufacturer" TEXT NOT NULL,
"name" TEXT NOT NULL,
"specs" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "asset_models_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "asset_models" ADD CONSTRAINT "asset_models_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "asset_categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `model` on the `asset_items` table. All the data in the column will be lost.
- Added the required column `model_id` to the `asset_items` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "asset_items" DROP COLUMN "model",
ADD COLUMN "model_id" UUID NOT NULL;
-- AddForeignKey
ALTER TABLE "asset_items" ADD CONSTRAINT "asset_items_model_id_fkey" FOREIGN KEY ("model_id") REFERENCES "asset_models"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "assignments" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"asset_item_id" UUID,
"license_id" UUID,
"assigned_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"returned_at" TIMESTAMPTZ(6),
"signed_at" TIMESTAMPTZ(6),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "assignments_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_asset_item_id_fkey" FOREIGN KEY ("asset_item_id") REFERENCES "asset_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_license_id_fkey" FOREIGN KEY ("license_id") REFERENCES "licenses"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -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;
+348 -14
View File
@@ -40,6 +40,15 @@ model Request {
// plain string (not an enum) until more states exist — see
// src/lib/requests.ts's RequestStatus type.
status String @default("Draft")
// Nullable FK to PurchaseOrder — see docs/asset-license-management.md's
// "## PurchaseOrder (gộp Receipt)" Traceability bullet. Semantically the
// DEMAND side, unlike AssetItem.purchaseOrderId/License.purchaseOrderId
// (supply-side intake): this records "which approved Request got grouped
// into this PO", not "which PO did this item come in through". Shape is
// identical to those two though — nullable FK, set at creation time only,
// no update endpoint for this field yet, same P2003-on-insert handling.
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("requests")
@@ -58,6 +67,13 @@ model Employee {
manager Employee? @relation("EmployeeManager", fields: [managerId], references: [id])
reports Employee[] @relation("EmployeeManager")
requests Request[]
// Back-relation for PurchaseOrder.receivedById — an Employee can be the
// receiver on multiple POs over time.
purchaseOrdersReceived PurchaseOrder[]
// Back-relation for Assignment.employeeId — an Employee can hold multiple
// Assignments (assets/licenses) over time, including several active ones
// at once (e.g. a laptop + a license seat simultaneously).
assignments Assignment[]
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("employees")
@@ -75,19 +91,45 @@ enum AssetItemStatus {
// Hardware inventory — see docs/asset-license-management.md's "Data model
// (sơ bộ)" table (serial number, model, ngày mua, bảo hành, trạng thái).
// No Vendor/Assignment relation yet — those entities don't exist yet
// (see "Việc còn dở" in CLAUDE.md), so this is a standalone entity for now,
// same starting shape as Employee before Request grew an FK to it.
// warrantyEndDate is nullable: not every asset has known warranty terms at
// intake time (smallest sensible assumption — doesn't block creation).
// No Assignment relation yet — that entity doesn't exist yet (see "Việc còn
// dở" in CLAUDE.md). warrantyEndDate is nullable: not every asset has known
// warranty terms at intake time (smallest sensible assumption — doesn't
// block creation). purchaseOrderId is a nullable FK to PurchaseOrder — see
// docs/asset-license-management.md's "## PurchaseOrder (gộp Receipt)"
// Traceability bullet: supply-side traceability ("which PO did this asset
// item come in through"). Nullable because not every AssetItem goes through
// a formal PO (e.g. historical stock predating the system). Set at
// intake/creation time only — no update endpoint for this field yet, same
// precedent as License.vendorId being set-at-creation with no PATCH.
// `modelId` was a free-text `model` String until the "Cập nhật 2026-08-02
// (2)" retrofit (docs/asset-license-management.md's "Ràng buộc dữ liệu"
// bullet) — same precedent as Request.employeeId/License.vendorId (string
// -> FK once the referenced entity existed). No production `AssetItem` data
// existed at the time of this change, so this was a plain column-type
// migration, not a backfill. Required (not nullable) — every AssetItem must
// belong to a real AssetModel. AssetItem deliberately does NOT carry its own
// `categoryId` — category is derived via `modelId -> AssetModel.categoryId`
// to avoid two sources of truth that could drift apart.
model AssetItem {
id String @id @default(uuid()) @db.Uuid
serialNumber String @unique @map("serial_number")
model String
modelId String @map("model_id") @db.Uuid
model AssetModel @relation(fields: [modelId], references: [id])
purchaseDate DateTime @map("purchase_date") @db.Date
warrantyEndDate DateTime? @map("warranty_end_date") @db.Date
status AssetItemStatus @default(in_stock)
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Back-relation for Assignment.assetItemId — an AssetItem can have
// 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")
}
@@ -102,26 +144,271 @@ enum LicenseType {
// Software license inventory — see docs/asset-license-management.md's "Data
// model (sơ bộ)" table (tên, vendor, tổng seat, seat đã dùng, ngày hết hạn,
// loại). `vendor` is deliberately a plain String for now, not a separate
// Vendor entity — no Vendor entity exists yet, same "plain string for now"
// precedent as Employee.department. `seatsUsed` is a stored counter
// (defaults to 0 at creation), not a computed value — it will only ever be
// set to 0 here since Assignment (the thing that would consume seats)
// doesn't exist yet. `expiryDate` is nullable: perpetual licenses never
// expire, only subscription licenses usually have one.
// loại). `vendorId` was a free-text `vendor` String until the "## Vendor
// (chốt 2026-08-02)" retrofit — same precedent as Request.employeeId
// (string -> FK once Employee existed). No production `License` data
// existed at the time of this change, so this was a plain column-type
// migration, not a backfill. `seatsUsed` is a stored counter (defaults to 0
// at creation), not a computed value — it will only ever be set to 0 here
// since Assignment (the thing that would consume seats) doesn't exist yet.
// `expiryDate` is nullable: perpetual licenses never expire, only
// subscription licenses usually have one. `purchaseOrderId` is a nullable FK
// to PurchaseOrder — see docs/asset-license-management.md's "## PurchaseOrder
// (gộp Receipt)" Traceability bullet: supply-side traceability ("which PO
// did this license come in through"). Nullable because not every License
// goes through a formal PO. Mirrors AssetItem.purchaseOrderId's just-merged
// pattern exactly — same nullable-FK shape, set at creation time only, no
// update endpoint for this field yet.
model License {
id String @id @default(uuid()) @db.Uuid
name String
vendor String
vendorId String @map("vendor_id") @db.Uuid
vendor Vendor @relation(fields: [vendorId], references: [id])
seatsTotal Int @map("seats_total")
seatsUsed Int @default(0) @map("seats_used")
expiryDate DateTime? @map("expiry_date") @db.Date
licenseType LicenseType @map("license_type")
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Back-relation for Assignment.licenseId — a License can have multiple
// Assignment rows (one per seat handed out, plus historical returns), not
// built until this task.
assignments Assignment[]
@@map("licenses")
}
// Purchasing counterparty — see docs/asset-license-management.md's "## Vendor
// (chốt 2026-08-02)". Deliberately independent of AssetModel.manufacturer
// (OEM, e.g. "Dell") — Vendor is the reseller/distributor actually bought
// from (e.g. "FPT Trading"), a different concept that isn't always the same
// company. `licenses` is the back-relation from the License.vendor ->
// License.vendorId retrofit (same doc section). `purchaseOrders` is the
// back-relation from PurchaseOrder.vendorId (see "## PurchaseOrder (gộp
// Receipt) (chốt 2026-08-02)"). taxCode/notes are nullable — not every
// vendor record has a known VN tax code or extra notes at creation time.
model Vendor {
id String @id @default(uuid()) @db.Uuid
name String
contactEmail String @map("contact_email")
contactPhone String @map("contact_phone")
address String
taxCode String? @map("tax_code")
notes String?
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")
}
// Purchasing document — see docs/asset-license-management.md's "##
// PurchaseOrder (gộp Receipt) (chốt 2026-08-02)". Deliberately absorbs what
// would otherwise be a separate `Receipt` entity: receiving is just part of
// this row's own state (`receivedAt`/`receivedBy`), not a separate table to
// reconcile against. No `status` field — status is inferred from linked
// `Request`s (Ordered -> Received -> Delivered), not stored here, to avoid
// two sources of truth. No cost/price fields — stakeholder said explicitly
// PurchaseOrder doesn't need to track money, only "which asset/license
// belongs to which PO". Receiving is all-or-nothing per PO (no partial
// receipt tracking) — a deliberate simplification, see doc for the
// stakeholder-accepted tradeoff. `receivedAt`/`receivedBy` are both
// nullable and must be co-null/co-set together (both-null = not yet
// received) — enforced at the Zod layer (src/lib/purchase-orders.ts), not a
// DB constraint, same approach as other cross-field invariants in this
// codebase (e.g. Assignment's assetItemId/licenseId XOR, not built yet).
// `assetItems` is the back-relation from the AssetItem.purchaseOrderId
// retrofit (supply-side traceability, same doc section). `licenses` is the
// back-relation from the License.purchaseOrderId retrofit (same doc
// section, same rationale). `requests` is the back-relation from the
// Request.purchaseOrderId retrofit — the DEMAND side ("which approved
// Request got grouped into this PO"), as opposed to assetItems/licenses
// which are the SUPPLY side (intake traceability) — see Request model's
// comment above for the distinction.
model PurchaseOrder {
id String @id @default(uuid()) @db.Uuid
vendorId String @map("vendor_id") @db.Uuid
vendor Vendor @relation(fields: [vendorId], references: [id])
orderDate DateTime @map("order_date") @db.Timestamptz(6)
expectedDate DateTime @map("expected_date") @db.Timestamptz(6)
receivedAt DateTime? @map("received_at") @db.Timestamptz(6)
receivedById String? @map("received_by") @db.Uuid
receivedBy Employee? @relation(fields: [receivedById], references: [id])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
assetItems AssetItem[]
licenses License[]
requests Request[]
@@map("purchase_orders")
}
// Fixed, closed set of 2 values tied to the domain (see docs/
// asset-license-management.md's "## Phân loại tài sản: High-value vs
// Low-value/Consumable" section) — native Postgres enum, same rationale as
// RequestType/AssetItemStatus/LicenseType above. Determines downstream
// rules for entities that will attach to a category later (not built in
// this task): AssetModel.categoryId must point to a `high_value` category,
// ConsumableItem.categoryId must point to a `low_value` category.
enum AssetCategoryValueGroup {
high_value
low_value
}
// Asset classification catalog — see docs/asset-license-management.md's
// "## Phân loại tài sản" section and the `AssetCategory` row in the "Data
// model (sơ bộ)" table. Standalone entity, no FKs (same starting shape as
// Vendor before it grew relations) — `AssetModel` and
// `AssetCategoryAttributeDef` will attach to this via `categoryId` in
// later tasks (not this one). IT admin defines the concrete categories
// (e.g. "Laptop", "Cáp HDMI") freely; only `valueGroup` is a fixed 2-value
// set.
model AssetCategory {
id String @id @default(uuid()) @db.Uuid
name String
valueGroup AssetCategoryValueGroup @map("value_group")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
attributeDefs AssetCategoryAttributeDef[]
// Back-relation for AssetModel.categoryId — see "Cập nhật 2026-08-02 (2)"
// in docs/asset-license-management.md. Only categories with
// valueGroup=high_value are ever actually referenced by an AssetModel (a
// business rule enforced in src/lib/asset-models.ts's createAssetModel(),
// not expressible as a Prisma-level constraint), but the relation itself
// is not restricted to high_value categories at the schema level.
assetModels AssetModel[]
@@map("asset_categories")
}
// Fixed, closed set of 4 values — a DEV-defined technical set (which Zod
// validator to build dynamically for AssetModel.specs at write time: string
// -> z.string(), number -> z.number(), boolean -> z.boolean(), date ->
// z.coerce.date()). Deliberately a different kind of "fixed set" than
// AssetCategoryValueGroup above: that one is a closed business classification
// IT admins never extend; this one is a closed set of *primitive types* the
// app's dynamic-attribute machinery knows how to handle. Still modeled as a
// native Postgres enum per the same codebase convention (RequestType /
// AssetItemStatus / LicenseType / AssetCategoryValueGroup), not a String +
// hand-rolled check.
enum AssetAttributeDataType {
string
number
boolean
date
}
// Per-category dynamic attribute definitions — see docs/
// asset-license-management.md's "Cập nhật 2026-08-02 (3): thêm
// AssetCategoryAttributeDef" section and the `AssetCategoryAttributeDef` row
// in the "Data model (sơ bộ)" table. Defines the *shape* of attributes a
// category cares about (e.g. category "Laptop" -> key `ram_gb`, label
// "RAM (GB)", dataType number); the actual *values* live in
// AssetModel.specs (JSONB), not built in this task. Each AssetCategory owns
// its own independent set of attribute defs — deliberately NOT a shared
// attribute library across categories (stakeholder explicitly rejected that
// design to keep things simple at current scale), so the same `key` string
// existing under two different categoryId rows is normal, not a collision —
// only unique *within* a category, hence the composite unique below (not a
// plain @unique on key alone). `sortOrder` is the display order on the
// dynamic form, no uniqueness constraint of its own (ties/gaps are fine,
// it's just a sort key).
model AssetCategoryAttributeDef {
id String @id @default(uuid()) @db.Uuid
categoryId String @map("category_id") @db.Uuid
category AssetCategory @relation(fields: [categoryId], references: [id])
key String
label String
dataType AssetAttributeDataType @map("data_type")
required Boolean
sortOrder Int @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@unique([categoryId, key])
@@map("asset_category_attribute_defs")
}
// Sits between AssetCategory and AssetItem — see docs/
// asset-license-management.md's "Cập nhật 2026-08-02 (2)" section and the
// `AssetModel` row in the "Data model (sơ bộ)" table. 1 AssetModel has many
// AssetItem serials (AssetItem.modelId FK, not built in this task).
// `manufacturer`/`name` are deliberately two separate String fields (not
// merged into one), per the doc, to allow filtering/reporting by
// manufacturer alone (e.g. "Dell") without string-parsing a combined field.
// `categoryId` is a required FK to AssetCategory — the DB layer doesn't
// enforce that the referenced category is `high_value` (Prisma/Postgres FK
// constraints can't express that), that business rule is checked in
// src/lib/asset-models.ts's createAssetModel() via an explicit lookup
// before the create, per the doc's "Ràng buộc dữ liệu" section. `specs` is
// JSONB, defaulting to an empty object — the real per-category dynamic
// attribute *values* (whose *shape* is defined by
// AssetCategoryAttributeDef rows for this model's category). This task
// deliberately does NOT validate specs against the category's attribute
// defs (loose z.record(z.unknown()) validation only, see
// src/lib/asset-models.ts) — that dynamic cross-validation is a separate
// follow-up task per the doc's "Cập nhật 2026-08-02 (3)" section.
model AssetModel {
id String @id @default(uuid()) @db.Uuid
categoryId String @map("category_id") @db.Uuid
category AssetCategory @relation(fields: [categoryId], references: [id])
manufacturer String
name String
specs Json @default("{}")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Back-relation for the AssetItem.model -> AssetItem.modelId retrofit —
// see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md.
assetItems AssetItem[]
@@map("asset_models")
}
// Assigns an AssetItem (high-value hardware) or a License seat to a specific
// Employee — see docs/asset-license-management.md's "## Assignment (chốt
// 2026-08-02)" section and the `Assignment` row in the "Data model (sơ bộ)"
// table. Exactly one of assetItemId/licenseId must be set (XOR) — enforced
// at the Zod layer (src/lib/assignments.ts's `.refine()`), NOT a DB
// constraint, per the same convention as PurchaseOrder's
// receivedAt/receivedById co-nullability check: Prisma schema can't express
// an XOR between two nullable FKs natively. Both FKs stay plain nullable
// columns here for that reason. `assignedAt` defaults to now() but is a
// real stored column (not derived from createdAt) because a future
// "transfer" operation (out of scope for this task, see doc) will create a
// new Assignment row whose assignedAt should reflect the transfer moment,
// not necessarily row-insert time. `returnedAt` null means still held by
// the employee — this is also what "active assignment" means throughout
// the codebase (see the uniqueness rule below). `signedAt` is deliberately
// just a nullable timestamp marking "handover paperwork signed" — NO file
// upload, stakeholder explicitly decided against storing a scanned file at
// this stage (see doc). No `@@unique` on assetItemId here for "only one
// active assignment at a time" — a plain unique constraint would also
// forbid a *second, already-returned* historical Assignment for the same
// AssetItem (e.g. item reassigned after being returned), which is valid and
// expected. The "at most one ACTIVE (returnedAt IS NULL) assignment per
// AssetItem" invariant is instead enforced in application code
// (src/lib/assignments.ts's createAssignment(), inside the same transaction
// that mutates AssetItem.status/License.seatsUsed) — a partial unique index
// (`WHERE returned_at IS NULL`) would express this at the DB level too, but
// that's raw SQL outside a Prisma-expressible `@@unique`, so deliberately
// not added here; see feature-implementer report for this trade-off.
model Assignment {
id String @id @default(uuid()) @db.Uuid
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], onDelete: Restrict)
licenseId String? @map("license_id") @db.Uuid
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)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("assignments")
}
// Minimal internal auth accounts — NOT company SSO (deliberately ruled out,
// see CLAUDE.md / auth decision notes). A handful of trusted IT staff
// accounts, created by hand via scripts/create-user.ts, no self-registration.
@@ -139,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
View File
@@ -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;
+51
View File
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {
createAssetCategory,
listAssetCategories,
validateCreateAssetCategoryInput,
} from "@/lib/asset-categories";
// Mirrors src/app/api/licenses/route.ts's pattern — middleware.ts excludes
// /api from route protection, so this checks the session itself. Flat
// permission model (CLAUDE.md): only "logged in or not", no role check.
export async function POST(request: Request): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
}
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { name, valueGroup } = body as Record<string, unknown>;
const validation = validateCreateAssetCategoryInput({ name, valueGroup });
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createAssetCategory(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 assetCategories = await listAssetCategories();
return NextResponse.json(assetCategories, { status: 200 });
}
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {
createAssetCategoryAttributeDef,
listAssetCategoryAttributeDefs,
validateCreateAssetCategoryAttributeDefInput,
} from "@/lib/asset-category-attribute-defs";
// Mirrors src/app/api/asset-categories/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 { categoryId, key, label, dataType, required, sortOrder } =
body as Record<string, unknown>;
const validation = validateCreateAssetCategoryAttributeDefInput({
categoryId,
key,
label,
dataType,
required,
sortOrder,
});
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createAssetCategoryAttributeDef(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 assetCategoryAttributeDefs = await listAssetCategoryAttributeDefs();
return NextResponse.json(assetCategoryAttributeDefs, { status: 200 });
}
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {
createAssetModel,
listAssetModels,
validateCreateAssetModelInput,
} from "@/lib/asset-models";
// Mirrors src/app/api/asset-category-attribute-defs/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 { categoryId, manufacturer, name, specs } = body as Record<string, unknown>;
const validation = validateCreateAssetModelInput({ categoryId, manufacturer, name, specs });
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createAssetModel(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 assetModels = await listAssetModels();
return NextResponse.json(assetModels, { status: 200 });
}
+4 -2
View File
@@ -22,12 +22,14 @@ export async function POST(request: Request): Promise<NextResponse> {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { serialNumber, model, purchaseDate, warrantyEndDate } = body as Record<string, unknown>;
const { serialNumber, modelId, purchaseDate, warrantyEndDate, purchaseOrderId } =
body as Record<string, unknown>;
const validation = validateCreateAssetItemInput({
serialNumber,
model,
modelId,
purchaseDate,
warrantyEndDate,
purchaseOrderId,
});
if (!validation.ok) {
+86
View File
@@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
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,
// bullet (b): update routes use PATCH /api/<entity>/[id], not an
// action-based sub-route (e.g. NOT /api/assignments/[id]/return). Next.js
// 14 App Router route handler params are a plain synchronous object (not a
// Promise) — that changed starting in Next.js 15, which this project does
// not use (see package.json / CLAUDE.md).
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
// returnAssignment()'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 { 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) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const updated = await returnAssignment(params.id, validation.value);
if (!updated.ok) {
return NextResponse.json({ error: updated.error }, { status: updated.status });
}
return NextResponse.json(updated.value, { status: 200 });
}
+51
View File
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {
createAssignment,
listAssignments,
validateCreateAssignmentInput,
} from "@/lib/assignments";
// Mirrors src/app/api/licenses/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 { employeeId, assetItemId, licenseId } = body as Record<string, unknown>;
const validation = validateCreateAssignmentInput({ employeeId, assetItemId, licenseId });
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createAssignment(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 assignments = await listAssignments();
return NextResponse.json(assignments, { status: 200 });
}
+8 -3
View File
@@ -22,13 +22,15 @@ export async function POST(request: Request): Promise<NextResponse> {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { name, vendor, seatsTotal, expiryDate, licenseType } = body as Record<string, unknown>;
const { name, vendorId, seatsTotal, expiryDate, licenseType, purchaseOrderId } =
body as Record<string, unknown>;
const validation = validateCreateLicenseInput({
name,
vendor,
vendorId,
seatsTotal,
expiryDate,
licenseType,
purchaseOrderId,
});
if (!validation.ok) {
@@ -36,7 +38,10 @@ export async function POST(request: Request): Promise<NextResponse> {
}
const created = await createLicense(validation.value);
return NextResponse.json(created, { status: 201 });
if (!created.ok) {
return NextResponse.json({ error: created.error }, { status: 400 });
}
return NextResponse.json(created.value, { status: 201 });
}
export async function GET(): Promise<NextResponse> {
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import {
closeMaintenanceRecord,
validateCloseMaintenanceRecordInput,
} from "@/lib/maintenance-records";
// Mirrors src/app/api/assignments/[id]/route.ts's pattern — PATCH is the
// only update action for MaintenanceRecord ("close"), no dispatch-on-body
// needed here since there is no second action shape (unlike Assignment's
// return-vs-transfer). Next.js 14 App Router route handler params are a
// plain synchronous object (not a Promise) — see the Assignment route's
// same comment for why.
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Validate the [id] path segment's format up front — an invalid UUID
// passed straight to Prisma's `findUnique({ where: { id } })` (a `@db.Uuid`
// column) throws a raw PrismaClientKnownRequestError that
// closeMaintenanceRecord()'s try/catch doesn't specifically handle, which
// would otherwise leak as an uncaught 500 instead of a clean 400.
if (!z.string().uuid().safeParse(params.id).success) {
return NextResponse.json({ error: "id must be a valid UUID string" }, { status: 400 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
}
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { endDate, resolutionNotes, targetStatus } = body as Record<string, unknown>;
const validation = validateCloseMaintenanceRecordInput({
endDate,
resolutionNotes,
targetStatus,
});
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const closed = await closeMaintenanceRecord(params.id, validation.value);
if (!closed.ok) {
return NextResponse.json({ error: closed.error }, { status: closed.status });
}
return NextResponse.json(closed.value, { status: 200 });
}
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {
createMaintenanceRecord,
listMaintenanceRecords,
validateCreateMaintenanceRecordInput,
} from "@/lib/maintenance-records";
// Mirrors src/app/api/assignments/route.ts's pattern — middleware.ts
// excludes /api from route protection, so this checks the session itself.
// Flat permission model (CLAUDE.md): only "logged in or not", no role check.
export async function POST(request: Request): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
}
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { assetItemId, type, vendorId, issueDescription, startDate } =
body as Record<string, unknown>;
const validation = validateCreateMaintenanceRecordInput({
assetItemId,
type,
vendorId,
issueDescription,
startDate,
});
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createMaintenanceRecord(validation.value);
if (!created.ok) {
return NextResponse.json({ error: created.error }, { status: 400 });
}
return NextResponse.json(created.value, { status: 201 });
}
export async function GET(): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const records = await listMaintenanceRecords();
return NextResponse.json(records, { status: 200 });
}
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {
createPurchaseOrder,
listPurchaseOrders,
validateCreatePurchaseOrderInput,
} from "@/lib/purchase-orders";
// Mirrors src/app/api/licenses/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 { vendorId, orderDate, expectedDate, receivedAt, receivedById } =
body as Record<string, unknown>;
const validation = validateCreatePurchaseOrderInput({
vendorId,
orderDate,
expectedDate,
receivedAt,
receivedById,
});
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createPurchaseOrder(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 purchaseOrders = await listPurchaseOrders();
return NextResponse.json(purchaseOrders, { status: 200 });
}
+2 -2
View File
@@ -24,8 +24,8 @@ export async function POST(request: Request): Promise<NextResponse> {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { employee_id, type, reason } = body as Record<string, unknown>;
const validation = validateCreateRequestInput({ employee_id, type, reason });
const { employee_id, type, reason, purchase_order_id } = body as Record<string, unknown>;
const validation = validateCreateRequestInput({ employee_id, type, reason, purchase_order_id });
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { createVendor, listVendors, validateCreateVendorInput } from "@/lib/vendors";
// Mirrors src/app/api/licenses/route.ts's pattern — middleware.ts excludes
// /api from route protection, so this checks the session itself. Flat
// permission model (CLAUDE.md): only "logged in or not", no role check.
export async function POST(request: Request): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
}
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { name, contactEmail, contactPhone, address, taxCode, notes } =
body as Record<string, unknown>;
const validation = validateCreateVendorInput({
name,
contactEmail,
contactPhone,
address,
taxCode,
notes,
});
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createVendor(validation.value);
return NextResponse.json(created, { status: 201 });
}
export async function GET(): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const vendors = await listVendors();
return NextResponse.json(vendors, { status: 200 });
}
+168
View File
@@ -0,0 +1,168 @@
"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 {
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/asset-categories — snake_case per this repo's
// API-boundary convention (see src/lib/asset-categories.ts's toRecord()).
interface AssetCategoryRecord {
id: string;
name: string;
value_group: "high_value" | "low_value";
created_at: string;
}
const VALUE_GROUP_OPTIONS = ["high_value", "low_value"] as const;
export default function AssetCategoriesPage() {
const {
data: assetCategories,
loading,
error,
refetch,
} = useApiList<AssetCategoryRecord>("/api/asset-categories");
const [name, setName] = useState("");
const [valueGroup, setValueGroup] = useState<(typeof VALUE_GROUP_OPTIONS)[number]>(
"high_value"
);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
try {
const response = await fetch("/api/asset-categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, valueGroup }),
});
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;
}
setName("");
setValueGroup("high_value");
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Asset Categories</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>Name</TableHead>
<TableHead>Value Group</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assetCategories.map((assetCategory) => (
<TableRow key={assetCategory.id}>
<TableCell>{assetCategory.id}</TableCell>
<TableCell>{assetCategory.name}</TableCell>
<TableCell>{assetCategory.value_group}</TableCell>
<TableCell>{assetCategory.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Asset Category</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="valueGroup">Value Group</Label>
<Select
value={valueGroup}
onValueChange={(value) =>
setValueGroup(value as (typeof VALUE_GROUP_OPTIONS)[number])
}
>
<SelectTrigger id="valueGroup" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VALUE_GROUP_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Asset Category"}
</Button>
</form>
</section>
</div>
);
}
@@ -0,0 +1,256 @@
"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 {
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/asset-category-attribute-defs — snake_case per
// this repo's API-boundary convention (see
// src/lib/asset-category-attribute-defs.ts's toRecord()).
interface AssetCategoryAttributeDefRecord {
id: string;
category_id: string;
key: string;
label: string;
data_type: "string" | "number" | "boolean" | "date";
required: boolean;
sort_order: number;
created_at: string;
}
// Shape returned by GET /api/asset-categories — snake_case per
// src/lib/asset-categories.ts's toRecord(). Only used here to populate the
// categoryId <Select> options.
interface AssetCategoryRecord {
id: string;
name: string;
value_group: "high_value" | "low_value";
created_at: string;
}
// Fixed 4-value set — mirrors src/lib/asset-category-attribute-defs.ts's
// dataType z.enum() literal values exactly.
const DATA_TYPE_OPTIONS = ["string", "number", "boolean", "date"] as const;
export default function AssetCategoryAttributeDefsPage() {
const {
data: attributeDefs,
loading,
error,
refetch,
} = useApiList<AssetCategoryAttributeDefRecord>("/api/asset-category-attribute-defs");
const { data: assetCategories } = useApiList<AssetCategoryRecord>("/api/asset-categories");
const [categoryId, setCategoryId] = useState("");
const [key, setKey] = useState("");
const [label, setLabel] = useState("");
const [dataType, setDataType] = useState<(typeof DATA_TYPE_OPTIONS)[number]>("string");
const [required, setRequired] = useState(false);
const [sortOrder, setSortOrder] = useState("0");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
try {
const response = await fetch("/api/asset-category-attribute-defs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
categoryId,
key,
label,
dataType,
required,
sortOrder: Number(sortOrder),
}),
});
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;
}
setCategoryId("");
setKey("");
setLabel("");
setDataType("string");
setRequired(false);
setSortOrder("0");
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Asset Category Attribute Defs</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>Category ID</TableHead>
<TableHead>Key</TableHead>
<TableHead>Label</TableHead>
<TableHead>Data Type</TableHead>
<TableHead>Required</TableHead>
<TableHead>Sort Order</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{attributeDefs.map((attributeDef) => (
<TableRow key={attributeDef.id}>
<TableCell>{attributeDef.id}</TableCell>
<TableCell>{attributeDef.category_id}</TableCell>
<TableCell>{attributeDef.key}</TableCell>
<TableCell>{attributeDef.label}</TableCell>
<TableCell>{attributeDef.data_type}</TableCell>
<TableCell>{attributeDef.required ? "Yes" : "No"}</TableCell>
<TableCell>{attributeDef.sort_order}</TableCell>
<TableCell>{attributeDef.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Asset Category Attribute Def</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="categoryId">Category</Label>
<Select value={categoryId} onValueChange={setCategoryId}>
<SelectTrigger id="categoryId" className="w-full">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{assetCategories.map((assetCategory) => (
<SelectItem key={assetCategory.id} value={assetCategory.id}>
{assetCategory.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="key">Key</Label>
<Input
id="key"
value={key}
onChange={(event) => setKey(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="label">Label</Label>
<Input
id="label"
value={label}
onChange={(event) => setLabel(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="dataType">Data Type</Label>
<Select
value={dataType}
onValueChange={(value) =>
setDataType(value as (typeof DATA_TYPE_OPTIONS)[number])
}
>
<SelectTrigger id="dataType" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_TYPE_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<input
id="required"
type="checkbox"
checked={required}
onChange={(event) => setRequired(event.target.checked)}
className="h-4 w-4"
/>
<Label htmlFor="required">Required</Label>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="sortOrder">Sort Order</Label>
<Input
id="sortOrder"
type="number"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value)}
required
/>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Attribute Def"}
</Button>
</form>
</section>
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
"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/asset-models — snake_case per this repo's
// API-boundary convention (see src/lib/asset-models.ts's toRecord()).
interface AssetModelRecord {
id: string;
category_id: string;
manufacturer: string;
name: string;
specs: Record<string, unknown>;
created_at: string;
}
// Shape returned by GET /api/asset-categories — used only to populate the
// categoryId <Select> here, not rendered as its own table. Deliberately NOT
// filtered to valueGroup=high_value client-side: the server
// (createAssetModel() in src/lib/asset-models.ts) enforces that rule and
// returns a clear {error} message if violated, which this page surfaces
// verbatim via submitError instead of duplicating the rule client-side.
interface AssetCategoryRecord {
id: string;
name: string;
value_group: "high_value" | "low_value";
created_at: string;
}
const DEFAULT_SPECS_TEXT = "{}";
export default function AssetModelsPage() {
const {
data: assetModels,
loading,
error,
refetch,
} = useApiList<AssetModelRecord>("/api/asset-models");
const { data: assetCategories } = useApiList<AssetCategoryRecord>(
"/api/asset-categories"
);
const [categoryId, setCategoryId] = useState("");
const [manufacturer, setManufacturer] = useState("");
const [name, setName] = useState("");
const [specsText, setSpecsText] = useState(DEFAULT_SPECS_TEXT);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
// specs is parsed client-side first — if it's not valid JSON, fail
// locally without calling the API at all (per task scope).
let specs: unknown;
try {
specs = JSON.parse(specsText);
} catch {
setSubmitError("Invalid JSON");
setSubmitting(false);
return;
}
try {
const response = await fetch("/api/asset-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ categoryId, manufacturer, name, specs }),
});
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;
}
setCategoryId("");
setManufacturer("");
setName("");
setSpecsText(DEFAULT_SPECS_TEXT);
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Asset Models</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>Category ID</TableHead>
<TableHead>Manufacturer</TableHead>
<TableHead>Name</TableHead>
<TableHead>Specs</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assetModels.map((assetModel) => (
<TableRow key={assetModel.id}>
<TableCell>{assetModel.id}</TableCell>
<TableCell>{assetModel.category_id}</TableCell>
<TableCell>{assetModel.manufacturer}</TableCell>
<TableCell>{assetModel.name}</TableCell>
<TableCell>{JSON.stringify(assetModel.specs)}</TableCell>
<TableCell>{assetModel.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Asset Model</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="categoryId">Category</Label>
<Select value={categoryId} onValueChange={setCategoryId}>
<SelectTrigger id="categoryId" className="w-full">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{assetCategories.map((assetCategory) => (
<SelectItem key={assetCategory.id} value={assetCategory.id}>
{assetCategory.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="manufacturer">Manufacturer</Label>
<Input
id="manufacturer"
value={manufacturer}
onChange={(event) => setManufacturer(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="specs">Specs (JSON)</Label>
<Textarea
id="specs"
value={specsText}
onChange={(event) => setSpecsText(event.target.value)}
rows={6}
/>
</div>
{submitError ? (
<p className="text-sm text-destructive">{submitError}</p>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Asset Model"}
</Button>
</form>
</section>
</div>
);
}
+254
View File
@@ -0,0 +1,254 @@
"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 {
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/assets — snake_case per this repo's
// API-boundary convention (see src/lib/assets.ts's toRecord()).
interface AssetItemRecord {
id: string;
serial_number: string;
model_id: string;
purchase_date: string;
warranty_end_date: string | null;
status: "in_stock" | "assigned" | "repair" | "retired";
purchase_order_id: string | null;
created_at: string;
}
// Shape returned by GET /api/asset-models — only the fields used to populate
// the modelId <Select> here (see src/app/asset-models/page.tsx's
// AssetModelRecord for the full shape).
interface AssetModelRecord {
id: string;
manufacturer: string;
name: string;
}
// Shape returned by GET /api/purchase-orders — only the id, used to populate
// the purchaseOrderId <Select> here. PurchaseOrder has no obviously
// human-readable name field (see src/app/purchase-orders/page.tsx's
// PurchaseOrderRecord for the full shape), so the id itself is shown as the
// label.
interface PurchaseOrderRecord {
id: string;
}
// Radix <Select.Item> can't take an empty-string value, so "no purchase
// order" is represented by this sentinel in the form state and translated to
// "omit the key" on submit — same pattern as purchase-orders page's
// NOT_RECEIVED_VALUE / employees page's NO_MANAGER_VALUE.
const NO_PURCHASE_ORDER_VALUE = "none";
export default function AssetsPage() {
const {
data: assetItems,
loading,
error,
refetch,
} = useApiList<AssetItemRecord>("/api/assets");
const { data: assetModels } = useApiList<AssetModelRecord>("/api/asset-models");
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
const [serialNumber, setSerialNumber] = useState("");
const [modelId, setModelId] = useState("");
const [purchaseDate, setPurchaseDate] = useState("");
const [warrantyEndDate, setWarrantyEndDate] = useState("");
const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
const hasWarrantyEndDate = warrantyEndDate.trim() !== "";
const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE;
try {
const response = await fetch("/api/assets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
serialNumber,
modelId,
purchaseDate,
// Omit optional keys entirely when left blank, rather than
// sending null — matches the create-form pattern used on other
// pages (e.g. purchase-orders page's receivedAt/receivedById).
...(hasWarrantyEndDate ? { warrantyEndDate } : {}),
...(hasPurchaseOrderId ? { purchaseOrderId } : {}),
}),
});
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;
}
setSerialNumber("");
setModelId("");
setPurchaseDate("");
setWarrantyEndDate("");
setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE);
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Assets</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>Serial Number</TableHead>
<TableHead>Model ID</TableHead>
<TableHead>Purchase Date</TableHead>
<TableHead>Warranty End Date</TableHead>
<TableHead>Status</TableHead>
<TableHead>Purchase Order ID</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assetItems.map((assetItem) => (
<TableRow key={assetItem.id}>
<TableCell>{assetItem.id}</TableCell>
<TableCell>{assetItem.serial_number}</TableCell>
<TableCell>{assetItem.model_id}</TableCell>
<TableCell>{assetItem.purchase_date}</TableCell>
<TableCell>{assetItem.warranty_end_date ?? "-"}</TableCell>
<TableCell>{assetItem.status}</TableCell>
<TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell>
<TableCell>{assetItem.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Asset</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="serialNumber">Serial Number</Label>
<Input
id="serialNumber"
value={serialNumber}
onChange={(event) => setSerialNumber(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="modelId">Model</Label>
<Select value={modelId} onValueChange={setModelId} required>
<SelectTrigger id="modelId" className="w-full">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{assetModels.map((assetModel) => (
<SelectItem key={assetModel.id} value={assetModel.id}>
{assetModel.manufacturer} {assetModel.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="purchaseDate">Purchase Date</Label>
<Input
id="purchaseDate"
type="date"
value={purchaseDate}
onChange={(event) => setPurchaseDate(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="warrantyEndDate">Warranty End Date (optional)</Label>
<Input
id="warrantyEndDate"
type="date"
value={warrantyEndDate}
onChange={(event) => setWarrantyEndDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="purchaseOrderId">Purchase Order (optional)</Label>
<Select value={purchaseOrderId} onValueChange={setPurchaseOrderId}>
<SelectTrigger id="purchaseOrderId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PURCHASE_ORDER_VALUE}>None</SelectItem>
{purchaseOrders.map((purchaseOrder) => (
<SelectItem key={purchaseOrder.id} value={purchaseOrder.id}>
{purchaseOrder.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Asset"}
</Button>
</form>
</section>
</div>
);
}
+434
View File
@@ -0,0 +1,434 @@
"use client";
import { useState, type FormEvent } from "react";
import { useApiList } from "@/hooks/use-api-list";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
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/assignments — snake_case per this repo's
// API-boundary convention (see src/lib/assignments.ts's toRecord()).
interface AssignmentRecord {
id: string;
employee_id: string;
asset_item_id: string | null;
license_id: string | null;
assigned_at: string;
returned_at: string | null;
signed_at: string | null;
created_at: string;
}
// Shape returned by GET /api/employees — only the field used to populate the
// employeeId <Select> here (see src/app/employees/page.tsx's EmployeeRecord
// for the full shape).
interface EmployeeRecord {
id: string;
name: 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/licenses — only the field used to populate the
// licenseId <Select> here (see src/app/licenses/page.tsx's LicenseRecord for
// the full shape).
interface LicenseRecord {
id: string;
name: string;
}
// The create form's XOR toggle — mirrors the server's own XOR rule in
// src/lib/assignments.ts's createAssignmentSchema .refine() (exactly one of
// assetItemId/licenseId must be set). This is purely a UI convenience to
// decide which single <Select> to show/submit — the actual business rule is
// still enforced server-side, this doesn't reimplement it.
type TargetKind = "asset_item" | "license";
export default function AssignmentsPage() {
const {
data: assignments,
loading,
error,
refetch,
} = useApiList<AssignmentRecord>("/api/assignments");
const { data: employees } = useApiList<EmployeeRecord>("/api/employees");
const { data: assetItems } = useApiList<AssetItemRecord>("/api/assets");
const { data: licenses } = useApiList<LicenseRecord>("/api/licenses");
const [employeeId, setEmployeeId] = useState("");
const [targetKind, setTargetKind] = useState<TargetKind>("asset_item");
const [assetItemId, setAssetItemId] = useState("");
const [licenseId, setLicenseId] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// Per-row error surfaced next to the "Return" button that was clicked, keyed
// by assignment id — kept simple (no modal) per task scope.
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);
setSubmitting(true);
try {
const response = await fetch("/api/assignments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
employeeId,
// Only the one FK relevant to the selected toggle is sent — mirrors
// the server's XOR rule (see createAssignmentSchema's .refine()).
...(targetKind === "asset_item" ? { assetItemId } : { licenseId }),
}),
});
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;
}
setEmployeeId("");
setTargetKind("asset_item");
setAssetItemId("");
setLicenseId("");
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
async function handleReturn(id: string) {
setReturningId(id);
setReturnErrors((previous) => {
const next = { ...previous };
delete next[id];
return next;
});
try {
// PATCH /api/assignments/[id]'s body is `{returnedAt?: string}` — see
// src/lib/assignments.ts's validateReturnAssignmentInput(). returnedAt
// is optional and defaults to "now" server-side (returnAssignment()),
// so an empty body is sent here rather than guessing a timestamp
// client-side.
const response = await fetch(`/api/assignments/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
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";
setReturnErrors((previous) => ({ ...previous, [id]: message }));
return;
}
refetch();
} catch {
setReturnErrors((previous) => ({ ...previous, [id]: "Request failed" }));
} finally {
setReturningId(null);
}
}
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>
<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>Employee ID</TableHead>
<TableHead>Asset Item ID</TableHead>
<TableHead>License ID</TableHead>
<TableHead>Assigned At</TableHead>
<TableHead>Returned At</TableHead>
<TableHead>Signed At</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((assignment) => (
<TableRow key={assignment.id}>
<TableCell>{assignment.id}</TableCell>
<TableCell>{assignment.employee_id}</TableCell>
<TableCell>{assignment.asset_item_id ?? "-"}</TableCell>
<TableCell>{assignment.license_id ?? "-"}</TableCell>
<TableCell>{assignment.assigned_at}</TableCell>
<TableCell>{assignment.returned_at ?? "-"}</TableCell>
<TableCell>{assignment.signed_at ?? "-"}</TableCell>
<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"
size="sm"
variant="outline"
disabled={returningId === assignment.id}
onClick={() => handleReturn(assignment.id)}
>
{returningId === assignment.id ? "Returning..." : "Return"}
</Button>
{returnErrors[assignment.id] ? (
<p className="text-xs text-destructive">
{returnErrors[assignment.id]}
</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>
) : (
"-"
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Assignment</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="employeeId">Employee</Label>
<Select value={employeeId} onValueChange={setEmployeeId} required>
<SelectTrigger id="employeeId" className="w-full">
<SelectValue placeholder="Select an employee" />
</SelectTrigger>
<SelectContent>
{employees.map((employee) => (
<SelectItem key={employee.id} value={employee.id}>
{employee.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="targetKind">Assign</Label>
<Select
value={targetKind}
onValueChange={(value) => setTargetKind(value as TargetKind)}
>
<SelectTrigger id="targetKind" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="asset_item">Asset Item</SelectItem>
<SelectItem value="license">License</SelectItem>
</SelectContent>
</Select>
</div>
{targetKind === "asset_item" ? (
<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="licenseId">License</Label>
<Select value={licenseId} onValueChange={setLicenseId} required>
<SelectTrigger id="licenseId" className="w-full">
<SelectValue placeholder="Select a license" />
</SelectTrigger>
<SelectContent>
{licenses.map((license) => (
<SelectItem key={license.id} value={license.id}>
{license.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Assignment"}
</Button>
</form>
</section>
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
"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 {
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/employees — snake_case per this repo's
// API-boundary convention (see src/lib/employees.ts's toRecord()).
interface EmployeeRecord {
id: string;
name: string;
department: string;
manager_id: string | null;
created_at: string;
}
// Radix <Select.Item> can't take an empty-string value, so "no manager" is
// represented by this sentinel in the form state and translated to
// `undefined` (omitted from the request body) on submit.
const NO_MANAGER_VALUE = "none";
export default function EmployeesPage() {
const { data: employees, loading, error, refetch } = useApiList<EmployeeRecord>(
"/api/employees"
);
const [name, setName] = useState("");
const [department, setDepartment] = useState("");
const [managerId, setManagerId] = useState(NO_MANAGER_VALUE);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
try {
const response = await fetch("/api/employees", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
department,
...(managerId !== NO_MANAGER_VALUE ? { managerId } : {}),
}),
});
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;
}
setName("");
setDepartment("");
setManagerId(NO_MANAGER_VALUE);
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Employees</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>Name</TableHead>
<TableHead>Department</TableHead>
<TableHead>Manager ID</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{employees.map((employee) => (
<TableRow key={employee.id}>
<TableCell>{employee.id}</TableCell>
<TableCell>{employee.name}</TableCell>
<TableCell>{employee.department}</TableCell>
<TableCell>{employee.manager_id ?? "-"}</TableCell>
<TableCell>{employee.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Employee</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="department">Department</Label>
<Input
id="department"
value={department}
onChange={(event) => setDepartment(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="managerId">Manager</Label>
<Select value={managerId} onValueChange={setManagerId}>
<SelectTrigger id="managerId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_MANAGER_VALUE}>None</SelectItem>
{employees.map((employee) => (
<SelectItem key={employee.id} value={employee.id}>
{employee.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? (
<p className="text-sm text-destructive">{submitError}</p>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Employee"}
</Button>
</form>
</section>
</div>
);
}
+14 -2
View File
@@ -1,15 +1,27 @@
import type { ReactNode } from "react";
import "./globals.css";
import { auth } from "@/auth";
import { Nav } from "@/components/nav";
export const metadata = {
title: "Asset & License Management",
description: "Internal IT asset & license management — Ubisoft VN local IT",
};
export default function RootLayout({ children }: { children: ReactNode }) {
// Root layout wraps every route, including /login (middleware.ts can't
// exclude it — it's the one page unauthenticated users must be able to
// reach). Gate <Nav/> on session presence here rather than rendering it
// unconditionally, so it only shows on authenticated pages as intended,
// without needing a client-side pathname check.
export default async function RootLayout({ children }: { children: ReactNode }) {
const session = await auth();
return (
<html lang="en">
<body>{children}</body>
<body>
{session ? <Nav /> : null}
{children}
</body>
</html>
);
}
+270
View File
@@ -0,0 +1,270 @@
"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 {
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/licenses — snake_case per this repo's
// API-boundary convention (see src/lib/licenses.ts's toRecord()).
interface LicenseRecord {
id: string;
name: string;
vendor_id: string;
seats_total: number;
seats_used: number;
expiry_date: string | null;
license_type: "perpetual" | "subscription";
purchase_order_id: string | null;
created_at: string;
}
// Shape returned by GET /api/vendors — only the fields used to populate the
// vendorId <Select> (see src/app/vendors/page.tsx's VendorRecord for the
// full shape).
interface VendorRecord {
id: string;
name: string;
}
// Shape returned by GET /api/purchase-orders — only the field used to
// populate the purchaseOrderId <Select> (id as label — a PurchaseOrder has
// no human-friendly name field, see src/app/purchase-orders/page.tsx).
interface PurchaseOrderRecord {
id: string;
}
// Radix <Select.Item> can't take an empty-string value, so "no purchase
// order" is represented by this sentinel in the form state and translated
// to "omit the key" on submit — same pattern as purchase-orders page's
// NOT_RECEIVED_VALUE / employees page's NO_MANAGER_VALUE.
const NO_PURCHASE_ORDER_VALUE = "none";
export default function LicensesPage() {
const {
data: licenses,
loading,
error,
refetch,
} = useApiList<LicenseRecord>("/api/licenses");
const { data: vendors } = useApiList<VendorRecord>("/api/vendors");
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>(
"/api/purchase-orders"
);
const [name, setName] = useState("");
const [vendorId, setVendorId] = useState("");
const [seatsTotal, setSeatsTotal] = useState("");
const [expiryDate, setExpiryDate] = useState("");
const [licenseType, setLicenseType] = useState("");
const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
setSubmitting(true);
try {
const response = await fetch("/api/licenses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
vendorId,
seatsTotal: Number(seatsTotal),
licenseType,
// Omit optional keys entirely when left blank, rather than
// sending null — matches the create-form pattern used on other
// pages (e.g. purchase-orders page's receivedAt/receivedById).
...(expiryDate.trim() !== "" ? { expiryDate } : {}),
...(purchaseOrderId !== NO_PURCHASE_ORDER_VALUE ? { purchaseOrderId } : {}),
}),
});
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;
}
setName("");
setVendorId("");
setSeatsTotal("");
setExpiryDate("");
setLicenseType("");
setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE);
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Licenses</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>Name</TableHead>
<TableHead>Vendor ID</TableHead>
<TableHead>Seats Total</TableHead>
<TableHead>Seats Used</TableHead>
<TableHead>Expiry Date</TableHead>
<TableHead>License Type</TableHead>
<TableHead>Purchase Order ID</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{licenses.map((license) => (
<TableRow key={license.id}>
<TableCell>{license.id}</TableCell>
<TableCell>{license.name}</TableCell>
<TableCell>{license.vendor_id}</TableCell>
<TableCell>{license.seats_total}</TableCell>
<TableCell>{license.seats_used}</TableCell>
<TableCell>{license.expiry_date ?? "-"}</TableCell>
<TableCell>{license.license_type}</TableCell>
<TableCell>{license.purchase_order_id ?? "-"}</TableCell>
<TableCell>{license.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New License</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="vendorId">Vendor</Label>
<Select value={vendorId} onValueChange={setVendorId} required>
<SelectTrigger id="vendorId" className="w-full">
<SelectValue placeholder="Select a vendor" />
</SelectTrigger>
<SelectContent>
{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="seatsTotal">Seats Total</Label>
<Input
id="seatsTotal"
type="number"
min={1}
step={1}
value={seatsTotal}
onChange={(event) => setSeatsTotal(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="expiryDate">Expiry Date (optional)</Label>
<Input
id="expiryDate"
type="date"
value={expiryDate}
onChange={(event) => setExpiryDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="licenseType">License Type</Label>
<Select value={licenseType} onValueChange={setLicenseType} required>
<SelectTrigger id="licenseType" className="w-full">
<SelectValue placeholder="Select a license type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="perpetual">perpetual</SelectItem>
<SelectItem value="subscription">subscription</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="purchaseOrderId">Purchase Order (optional)</Label>
<Select value={purchaseOrderId} onValueChange={setPurchaseOrderId}>
<SelectTrigger id="purchaseOrderId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PURCHASE_ORDER_VALUE}>None</SelectItem>
{purchaseOrders.map((purchaseOrder) => (
<SelectItem key={purchaseOrder.id} value={purchaseOrder.id}>
{purchaseOrder.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create License"}
</Button>
</form>
</section>
</div>
);
}
+415
View File
@@ -0,0 +1,415 @@
"use client";
import { useState, type FormEvent } from "react";
import { useApiList } from "@/hooks/use-api-list";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Shape returned by GET /api/maintenance-records — snake_case per this
// repo's API-boundary convention (see src/lib/maintenance-records.ts's
// toRecord()). An "open" record is one where end_date is null.
interface MaintenanceRecordRecord {
id: string;
asset_item_id: string;
type: "repair" | "warranty" | "preventive";
vendor_id: string | null;
issue_description: string;
resolution_notes: string | null;
start_date: string;
end_date: string | null;
created_at: string;
}
// Shape returned by GET /api/assets — only the field used to populate the
// assetItemId <Select> here (see src/app/assets/page.tsx's AssetItemRecord
// for the full shape).
interface AssetItemRecord {
id: string;
serial_number: string;
}
// Shape returned by GET /api/vendors — only the field used to populate the
// vendorId <Select> here (see src/app/vendors/page.tsx's VendorRecord for
// the full shape).
interface VendorRecord {
id: string;
name: string;
}
// The 4 AssetItemStatus enum values, reused verbatim as the targetStatus
// <Select>'s option list — mirrors src/lib/maintenance-records.ts's own
// assetItemStatusValues constant (not re-derived from an API call, this is
// a closed set already known client-side, same precedent as licenses
// page's licenseType/perpetual|subscription <Select>).
const ASSET_ITEM_STATUS_VALUES = ["in_stock", "assigned", "repair", "retired"] as const;
// Radix <Select.Item> can't take an empty-string value, so "no vendor" is
// represented by this sentinel in the form state and translated to "omit
// the key" on submit — same pattern as licenses page's
// NO_PURCHASE_ORDER_VALUE / employees page's NO_MANAGER_VALUE.
const NO_VENDOR_VALUE = "none";
function todayDateString(): string {
return new Date().toISOString().slice(0, 10);
}
export default function MaintenanceRecordsPage() {
const {
data: maintenanceRecords,
loading,
error,
refetch,
} = useApiList<MaintenanceRecordRecord>("/api/maintenance-records");
const { data: assetItems } = useApiList<AssetItemRecord>("/api/assets");
const { data: vendors } = useApiList<VendorRecord>("/api/vendors");
const [assetItemId, setAssetItemId] = useState("");
const [type, setType] = useState("");
const [vendorId, setVendorId] = useState(NO_VENDOR_VALUE);
const [issueDescription, setIssueDescription] = useState("");
const [startDate, setStartDate] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// Per-row close mini-form state, keyed by maintenance record id — kept as
// separate maps (rather than one nested-object map) to keep each field's
// setter a plain string setter, same shape as returnErrors/returningId on
// assignments/page.tsx which this page mirrors.
const [closeEndDate, setCloseEndDate] = useState<Record<string, string>>({});
const [closeResolutionNotes, setCloseResolutionNotes] = useState<Record<string, string>>({});
const [closeTargetStatus, setCloseTargetStatus] = useState<Record<string, string>>({});
const [closeErrors, setCloseErrors] = useState<Record<string, string>>({});
const [closingId, setClosingId] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
setSubmitting(true);
try {
const response = await fetch("/api/maintenance-records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
assetItemId,
type,
issueDescription,
startDate,
// Omit the key entirely when left unselected, rather than sending
// null — matches licenses page's purchaseOrderId pattern.
...(vendorId !== NO_VENDOR_VALUE ? { vendorId } : {}),
}),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setSubmitError(message);
return;
}
setAssetItemId("");
setType("");
setVendorId(NO_VENDOR_VALUE);
setIssueDescription("");
setStartDate("");
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
async function handleClose(id: string) {
setClosingId(id);
setCloseErrors((previous) => {
const next = { ...previous };
delete next[id];
return next;
});
const endDate = closeEndDate[id] ?? todayDateString();
const resolutionNotes = closeResolutionNotes[id] ?? "";
const targetStatus = closeTargetStatus[id] ?? "";
try {
// PATCH /api/maintenance-records/[id]'s body is
// `{endDate, resolutionNotes?, targetStatus}` — see src/lib/
// maintenance-records.ts's validateCloseMaintenanceRecordInput().
// Unlike Assignment's returnedAt, targetStatus is REQUIRED and never
// defaulted server-side, so it's always sent here (an empty string
// if unselected, which the server rejects with a clean 400 message
// surfaced below, same as any other validation failure).
const response = await fetch(`/api/maintenance-records/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
endDate,
targetStatus,
...(resolutionNotes.trim() !== "" ? { resolutionNotes } : {}),
}),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setCloseErrors((previous) => ({ ...previous, [id]: message }));
return;
}
setCloseEndDate((previous) => {
const next = { ...previous };
delete next[id];
return next;
});
setCloseResolutionNotes((previous) => {
const next = { ...previous };
delete next[id];
return next;
});
setCloseTargetStatus((previous) => {
const next = { ...previous };
delete next[id];
return next;
});
refetch();
} catch {
setCloseErrors((previous) => ({ ...previous, [id]: "Request failed" }));
} finally {
setClosingId(null);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Maintenance Records</h1>
<section className="flex flex-col gap-3">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Asset Item ID</TableHead>
<TableHead>Type</TableHead>
<TableHead>Vendor ID</TableHead>
<TableHead>Issue Description</TableHead>
<TableHead>Resolution Notes</TableHead>
<TableHead>Start Date</TableHead>
<TableHead>End Date</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{maintenanceRecords.map((record) => (
<TableRow key={record.id}>
<TableCell>{record.id}</TableCell>
<TableCell>{record.asset_item_id}</TableCell>
<TableCell>{record.type}</TableCell>
<TableCell>{record.vendor_id ?? "-"}</TableCell>
<TableCell>{record.issue_description}</TableCell>
<TableCell>{record.resolution_notes ?? "-"}</TableCell>
<TableCell>{record.start_date}</TableCell>
<TableCell>{record.end_date ?? "-"}</TableCell>
<TableCell>{record.created_at}</TableCell>
<TableCell>
{record.end_date === null ? (
<div className="flex w-56 flex-col gap-1">
<Input
type="date"
aria-label="End date"
value={closeEndDate[record.id] ?? todayDateString()}
onChange={(event) =>
setCloseEndDate((previous) => ({
...previous,
[record.id]: event.target.value,
}))
}
/>
<Input
type="text"
placeholder="Resolution notes (optional)"
value={closeResolutionNotes[record.id] ?? ""}
onChange={(event) =>
setCloseResolutionNotes((previous) => ({
...previous,
[record.id]: event.target.value,
}))
}
/>
<Select
value={closeTargetStatus[record.id] ?? ""}
onValueChange={(value) =>
setCloseTargetStatus((previous) => ({
...previous,
[record.id]: value,
}))
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Target status" />
</SelectTrigger>
<SelectContent>
{ASSET_ITEM_STATUS_VALUES.map((status) => (
<SelectItem key={status} value={status}>
{status}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
variant="outline"
disabled={closingId === record.id}
onClick={() => handleClose(record.id)}
>
{closingId === record.id ? "Closing..." : "Close"}
</Button>
{closeErrors[record.id] ? (
<p className="text-xs text-destructive">
{closeErrors[record.id]}
</p>
) : null}
</div>
) : (
"-"
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Maintenance Record</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="assetItemId">Asset Item</Label>
<Select value={assetItemId} onValueChange={setAssetItemId} required>
<SelectTrigger id="assetItemId" className="w-full">
<SelectValue placeholder="Select an asset item" />
</SelectTrigger>
<SelectContent>
{assetItems.map((assetItem) => (
<SelectItem key={assetItem.id} value={assetItem.id}>
{assetItem.serial_number}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="type">Type</Label>
<Select value={type} onValueChange={setType} required>
<SelectTrigger id="type" className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="repair">repair</SelectItem>
<SelectItem value="warranty">warranty</SelectItem>
<SelectItem value="preventive">preventive</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="vendorId">Vendor (optional)</Label>
<Select value={vendorId} onValueChange={setVendorId}>
<SelectTrigger id="vendorId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_VENDOR_VALUE}>None</SelectItem>
{vendors.map((vendor) => (
<SelectItem key={vendor.id} value={vendor.id}>
{vendor.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="issueDescription">Issue Description</Label>
<Textarea
id="issueDescription"
value={issueDescription}
onChange={(event) => setIssueDescription(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="startDate">Start Date</Label>
<Input
id="startDate"
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
required
/>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Maintenance Record"}
</Button>
</form>
</section>
</div>
);
}
+258
View File
@@ -0,0 +1,258 @@
"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 {
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/purchase-orders — snake_case per this repo's
// API-boundary convention (see src/lib/purchase-orders.ts's toRecord()).
interface PurchaseOrderRecord {
id: string;
vendor_id: string;
order_date: string;
expected_date: string;
received_at: string | null;
received_by: string | null;
created_at: string;
}
// Shape returned by GET /api/vendors — only the fields used to populate the
// vendorId <Select> (see src/app/vendors/page.tsx's VendorRecord for the
// full shape).
interface VendorRecord {
id: string;
name: string;
}
// Shape returned by GET /api/employees — only the fields used to populate
// the receivedById <Select> (see src/app/employees/page.tsx's
// EmployeeRecord for the full shape).
interface EmployeeRecord {
id: string;
name: string;
}
// Radix <Select.Item> can't take an empty-string value, so "not received
// yet" is represented by this sentinel in the form state and translated to
// "omit the key" on submit — same pattern as employees page's
// NO_MANAGER_VALUE.
const NOT_RECEIVED_VALUE = "none";
export default function PurchaseOrdersPage() {
const {
data: purchaseOrders,
loading,
error,
refetch,
} = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
const { data: vendors } = useApiList<VendorRecord>("/api/vendors");
const { data: employees } = useApiList<EmployeeRecord>("/api/employees");
const [vendorId, setVendorId] = useState("");
const [orderDate, setOrderDate] = useState("");
const [expectedDate, setExpectedDate] = useState("");
const [receivedAt, setReceivedAt] = useState("");
const [receivedById, setReceivedById] = useState(NOT_RECEIVED_VALUE);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
// Mirror the server's .refine(): receivedAt/receivedById must both be
// empty or both be filled (src/lib/purchase-orders.ts's
// createPurchaseOrderSchema). Server is still the source of truth if
// this check somehow disagrees with it.
const hasReceivedAt = receivedAt.trim() !== "";
const hasReceivedById = receivedById !== NOT_RECEIVED_VALUE;
if (hasReceivedAt !== hasReceivedById) {
setSubmitError("receivedAt and receivedById must both be set or both be unset");
return;
}
setSubmitting(true);
try {
const response = await fetch("/api/purchase-orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
vendorId,
orderDate,
expectedDate,
// Omit both keys entirely when left empty, rather than sending
// null — matches the create-form pattern used on other pages
// (e.g. employees page's managerId).
...(hasReceivedAt ? { receivedAt } : {}),
...(hasReceivedById ? { receivedById } : {}),
}),
});
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;
}
setVendorId("");
setOrderDate("");
setExpectedDate("");
setReceivedAt("");
setReceivedById(NOT_RECEIVED_VALUE);
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Purchase Orders</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>Vendor ID</TableHead>
<TableHead>Order Date</TableHead>
<TableHead>Expected Date</TableHead>
<TableHead>Received At</TableHead>
<TableHead>Received By</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchaseOrders.map((purchaseOrder) => (
<TableRow key={purchaseOrder.id}>
<TableCell>{purchaseOrder.id}</TableCell>
<TableCell>{purchaseOrder.vendor_id}</TableCell>
<TableCell>{purchaseOrder.order_date}</TableCell>
<TableCell>{purchaseOrder.expected_date}</TableCell>
<TableCell>{purchaseOrder.received_at ?? "-"}</TableCell>
<TableCell>{purchaseOrder.received_by ?? "-"}</TableCell>
<TableCell>{purchaseOrder.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Purchase Order</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="vendorId">Vendor</Label>
<Select value={vendorId} onValueChange={setVendorId} required>
<SelectTrigger id="vendorId" className="w-full">
<SelectValue placeholder="Select a vendor" />
</SelectTrigger>
<SelectContent>
{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="orderDate">Order Date</Label>
<Input
id="orderDate"
type="date"
value={orderDate}
onChange={(event) => setOrderDate(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="expectedDate">Expected Date</Label>
<Input
id="expectedDate"
type="date"
value={expectedDate}
onChange={(event) => setExpectedDate(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="receivedAt">Received At (optional)</Label>
<Input
id="receivedAt"
type="date"
value={receivedAt}
onChange={(event) => setReceivedAt(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="receivedById">Received By (optional)</Label>
<Select value={receivedById} onValueChange={setReceivedById}>
<SelectTrigger id="receivedById" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NOT_RECEIVED_VALUE}>Not received</SelectItem>
{employees.map((employee) => (
<SelectItem key={employee.id} value={employee.id}>
{employee.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Purchase Order"}
</Button>
</form>
</section>
</div>
);
}
+251
View File
@@ -0,0 +1,251 @@
"use client";
import { useState, type FormEvent } from "react";
import { useApiList } from "@/hooks/use-api-list";
import { Button } from "@/components/ui/button";
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/requests — snake_case per this repo's
// API-boundary convention (see src/lib/requests.ts's toRecord()). `status`
// is always "Draft" right now (no state-machine transitions built yet), see
// src/lib/requests.ts's RequestStatus comment.
interface RequestRecord {
id: string;
employee_id: string;
type: "asset" | "license";
reason: string;
status: string;
purchase_order_id: string | null;
created_at: string;
}
// Shape returned by GET /api/employees — only the fields used to populate
// the employeeId <Select> (see src/app/employees/page.tsx's EmployeeRecord
// for the full shape).
interface EmployeeRecord {
id: string;
name: string;
}
// Shape returned by GET /api/purchase-orders — only the id, shown as the
// <Select> label since PurchaseOrder has no human-friendly name field (see
// src/app/purchase-orders/page.tsx's PurchaseOrderRecord for the full
// shape).
interface PurchaseOrderRecord {
id: string;
}
// The exact enum literal values from RequestType (src/lib/requests.ts) —
// don't invent labels not accepted by the Zod schema.
const REQUEST_TYPES = ["asset", "license"] as const;
// Radix <Select.Item> can't take an empty-string value, so "no purchase
// order" is represented by this sentinel in the form state and translated
// to "omit the key" on submit — same pattern as employees page's
// NO_MANAGER_VALUE / purchase-orders page's NOT_RECEIVED_VALUE.
const NO_PURCHASE_ORDER_VALUE = "none";
export default function RequestsPage() {
const {
data: requests,
loading,
error,
refetch,
} = useApiList<RequestRecord>("/api/requests");
const { data: employees } = useApiList<EmployeeRecord>("/api/employees");
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>(
"/api/purchase-orders"
);
const [employeeId, setEmployeeId] = useState("");
const [type, setType] = useState<(typeof REQUEST_TYPES)[number] | "">("");
const [reason, setReason] = useState("");
const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE;
try {
const response = await fetch("/api/requests", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
employeeId,
type,
reason,
// Omit the key entirely when left unset, rather than sending
// null — matches the create-form pattern used on other pages
// (e.g. employees page's managerId).
...(hasPurchaseOrderId ? { purchaseOrderId } : {}),
}),
});
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;
}
setEmployeeId("");
setType("");
setReason("");
setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE);
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Requests</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>Employee ID</TableHead>
<TableHead>Type</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Status</TableHead>
<TableHead>Purchase Order ID</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requests.map((request) => (
<TableRow key={request.id}>
<TableCell>{request.id}</TableCell>
<TableCell>{request.employee_id}</TableCell>
<TableCell>{request.type}</TableCell>
<TableCell>{request.reason}</TableCell>
<TableCell>{request.status}</TableCell>
<TableCell>{request.purchase_order_id ?? "-"}</TableCell>
<TableCell>{request.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Request</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="employeeId">Employee</Label>
<Select value={employeeId} onValueChange={setEmployeeId} required>
<SelectTrigger id="employeeId" className="w-full">
<SelectValue placeholder="Select an employee" />
</SelectTrigger>
<SelectContent>
{employees.map((employee) => (
<SelectItem key={employee.id} value={employee.id}>
{employee.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="type">Type</Label>
<Select
value={type}
onValueChange={(value) => setType(value as (typeof REQUEST_TYPES)[number])}
required
>
<SelectTrigger id="type" className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
{REQUEST_TYPES.map((requestType) => (
<SelectItem key={requestType} value={requestType}>
{requestType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="reason">Reason</Label>
<Textarea
id="reason"
value={reason}
onChange={(event) => setReason(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="purchaseOrderId">Purchase Order (optional)</Label>
<Select value={purchaseOrderId} onValueChange={setPurchaseOrderId}>
<SelectTrigger id="purchaseOrderId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PURCHASE_ORDER_VALUE}>None</SelectItem>
{purchaseOrders.map((purchaseOrder) => (
<SelectItem key={purchaseOrder.id} value={purchaseOrder.id}>
{purchaseOrder.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Request"}
</Button>
</form>
</section>
</div>
);
}
+216
View File
@@ -0,0 +1,216 @@
"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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Shape returned by GET /api/vendors — snake_case per this repo's
// API-boundary convention (see src/lib/vendors.ts's toRecord()).
interface VendorRecord {
id: string;
name: string;
contact_email: string;
contact_phone: string;
address: string;
tax_code: string | null;
notes: string | null;
created_at: string;
}
export default function VendorsPage() {
const { data: vendors, loading, error, refetch } = useApiList<VendorRecord>(
"/api/vendors"
);
const [name, setName] = useState("");
const [contactEmail, setContactEmail] = useState("");
const [contactPhone, setContactPhone] = useState("");
const [address, setAddress] = useState("");
const [taxCode, setTaxCode] = useState("");
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
try {
const response = await fetch("/api/vendors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
contactEmail,
contactPhone,
address,
// taxCode/notes are optional+nullable and must be non-empty
// strings if present (src/lib/vendors.ts's createVendorSchema) —
// omit the key entirely when the field is left blank, same
// pattern as employees page's managerId.
...(taxCode.trim() !== "" ? { taxCode } : {}),
...(notes.trim() !== "" ? { notes } : {}),
}),
});
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;
}
setName("");
setContactEmail("");
setContactPhone("");
setAddress("");
setTaxCode("");
setNotes("");
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Vendors</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>Name</TableHead>
<TableHead>Contact Email</TableHead>
<TableHead>Contact Phone</TableHead>
<TableHead>Address</TableHead>
<TableHead>Tax Code</TableHead>
<TableHead>Notes</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vendors.map((vendor) => (
<TableRow key={vendor.id}>
<TableCell>{vendor.id}</TableCell>
<TableCell>{vendor.name}</TableCell>
<TableCell>{vendor.contact_email}</TableCell>
<TableCell>{vendor.contact_phone}</TableCell>
<TableCell>{vendor.address}</TableCell>
<TableCell>{vendor.tax_code ?? "-"}</TableCell>
<TableCell>{vendor.notes ?? "-"}</TableCell>
<TableCell>{vendor.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Vendor</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="contactEmail">Contact Email</Label>
<Input
id="contactEmail"
type="email"
value={contactEmail}
onChange={(event) => setContactEmail(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="contactPhone">Contact Phone</Label>
<Input
id="contactPhone"
value={contactPhone}
onChange={(event) => setContactPhone(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="address">Address</Label>
<Input
id="address"
value={address}
onChange={(event) => setAddress(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="taxCode">Tax Code</Label>
<Input
id="taxCode"
value={taxCode}
onChange={(event) => setTaxCode(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{submitError ? (
<p className="text-sm text-destructive">{submitError}</p>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Vendor"}
</Button>
</form>
</section>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import Link from "next/link";
import { signOut } from "next-auth/react";
import { Button } from "@/components/ui/button";
// Shared top nav for every authenticated page. Links point at the entity
// routes each later task in this "basic functional UI" batch will build
// (Employee, Vendor, AssetCategory, AssetCategoryAttributeDef, AssetModel,
// PurchaseOrder, AssetItem, License, Request, Assignment) — most of these
// routes don't exist yet and will 404 until their own task lands, which is
// expected (see task scope: no conditional hiding needed).
const ENTITY_LINKS = [
{ href: "/employees", label: "Employees" },
{ href: "/vendors", label: "Vendors" },
{ href: "/asset-categories", label: "Asset Categories" },
{ href: "/asset-category-attribute-defs", label: "Attribute Defs" },
{ href: "/asset-models", label: "Asset Models" },
{ href: "/assets", label: "Assets" },
{ href: "/licenses", label: "Licenses" },
{ 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() {
return (
<nav className="flex flex-wrap items-center gap-4 border-b border-border px-4 py-3">
<Link href="/" className="font-semibold whitespace-nowrap">
Asset & License Mgmt
</Link>
<div className="flex flex-1 flex-wrap gap-3 text-sm">
{ENTITY_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-muted-foreground hover:text-foreground"
>
{link.label}
</Link>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => signOut({ callbackUrl: "/login" })}
>
Sign out
</Button>
</nav>
);
}
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+190
View File
@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useCallback, useEffect, useState } from "react";
// Small shared data-fetching hook used by every entity page — both for its
// own list table (GET /api/<entity>) and for populating FK <Select>
// dropdowns from other entities' GET endpoints (e.g. an AssetItem page
// fetching /api/employees to build an "assigned to" picker). Deliberately
// minimal: no caching/dedupe/revalidation library, just fetch + state,
// matching the "no polish, just clickable manual testing" scope of this
// task (see CLAUDE.md's "basic functional UI" batch note).
//
// Every API route in this app returns a JSON array on success (200) or
// `{error: string}` on failure (see e.g. src/app/api/employees/route.ts's
// GET/POST) — this hook follows that same shape convention.
type UseApiListResult<T> = {
data: T[];
loading: boolean;
error: string | null;
refetch: () => void;
};
const GENERIC_ERROR_MESSAGE = "Request failed";
export function useApiList<T>(endpoint: string): UseApiListResult<T> {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refetchCount, setRefetchCount] = useState(0);
const refetch = useCallback(() => {
setRefetchCount((count) => count + 1);
}, []);
useEffect(() => {
let cancelled = false;
async function load(): Promise<void> {
setLoading(true);
setError(null);
try {
const response = await fetch(endpoint);
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
: GENERIC_ERROR_MESSAGE;
if (!cancelled) {
setData([]);
setError(message);
}
return;
}
if (!cancelled) {
setData(Array.isArray(body) ? (body as T[]) : []);
}
} catch {
if (!cancelled) {
setData([]);
setError(GENERIC_ERROR_MESSAGE);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [endpoint, refetchCount]);
return { data, loading, error, refetch };
}
+107
View File
@@ -0,0 +1,107 @@
import { z } from "zod";
import { prisma } from "./prisma";
export type AssetCategoryValueGroup = "high_value" | "low_value";
export interface AssetCategoryRecord {
id: string;
name: string;
value_group: AssetCategoryValueGroup;
created_at: string;
}
export interface CreateAssetCategoryInput {
name: string;
valueGroup: AssetCategoryValueGroup;
}
export type ValidationResult =
| { ok: true; value: CreateAssetCategoryInput }
| { ok: false; error: string };
// Mirrors src/lib/vendors.ts's nonEmptyString helper.
function nonEmptyString(message: string) {
return z.string({ required_error: message, invalid_type_error: message }).refine(
(value) => value.trim() !== "",
{ message }
);
}
const createAssetCategorySchema = z.object({
name: nonEmptyString("name is required and must be a non-empty string"),
// Fixed 2-value set (see docs/asset-license-management.md's "## Phân
// loại tài sản" section) — mirrors src/lib/licenses.ts's licenseType
// z.enum() pattern.
valueGroup: z.enum(["high_value", "low_value"], {
errorMap: () => ({
message: "valueGroup is required and must be either 'high_value' or 'low_value'",
}),
}),
});
/**
* Validates raw (untyped) asset-category-creation input. name must be a
* non-empty string, valueGroup must be one of the two fixed enum values
* (per the "## Phân loại tài sản" spec in docs/asset-license-management.md).
*/
export function validateCreateAssetCategoryInput(input: {
name?: unknown;
valueGroup?: unknown;
}): ValidationResult {
const result = createAssetCategorySchema.safeParse(input);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid input";
return { ok: false, error: message };
}
return { ok: true, value: result.data };
}
function toRecord(row: {
id: string;
name: string;
valueGroup: AssetCategoryValueGroup;
createdAt: Date;
}): AssetCategoryRecord {
return {
id: row.id,
name: row.name,
value_group: row.valueGroup,
created_at: row.createdAt.toISOString(),
};
}
export type CreateAssetCategoryResult =
| { ok: true; value: AssetCategoryRecord }
| { ok: false; error: string };
/**
* Creates an AssetCategory. No unique constraint on any field, no FKs
* either (see docs/asset-license-management.md's "## Phân loại tài sản"
* spec — standalone entity, same starting shape as Vendor before it grew
* relations) — so there's no known Prisma error code to catch/translate
* here today. Still returns the `{ok,value}|{ok,error}` shape (rather than
* a bare Promise<AssetCategoryRecord> like createVendor()) to match the
* convention used across the newer entities (AssetItem/License) — see the
* code-reviewer finding on createLicense() in CLAUDE.md's "Trạng thái hiện
* tại": adding this shape up front avoids a silent 500 if a unique
* constraint is added later (e.g. on `name`) without remembering to also
* add P2002 handling here.
*/
export async function createAssetCategory(
input: CreateAssetCategoryInput
): Promise<CreateAssetCategoryResult> {
const created = await prisma.assetCategory.create({
data: {
name: input.name,
valueGroup: input.valueGroup,
},
});
return { ok: true, value: toRecord(created) };
}
export async function listAssetCategories(): Promise<AssetCategoryRecord[]> {
const rows = await prisma.assetCategory.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
+169
View File
@@ -0,0 +1,169 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
export type AssetAttributeDataType = "string" | "number" | "boolean" | "date";
export interface AssetCategoryAttributeDefRecord {
id: string;
category_id: string;
key: string;
label: string;
data_type: AssetAttributeDataType;
required: boolean;
sort_order: number;
created_at: string;
}
export interface CreateAssetCategoryAttributeDefInput {
categoryId: string;
key: string;
label: string;
dataType: AssetAttributeDataType;
required: boolean;
sortOrder: number;
}
export type ValidationResult =
| { ok: true; value: CreateAssetCategoryAttributeDefInput }
| { ok: false; error: string };
// Mirrors src/lib/asset-categories.ts's nonEmptyString helper.
function nonEmptyString(message: string) {
return z.string({ required_error: message, invalid_type_error: message }).refine(
(value) => value.trim() !== "",
{ message }
);
}
const createAssetCategoryAttributeDefSchema = z.object({
// FK to AssetCategory — only a UUID *format* check here, whether it
// references a real category is left to Prisma's own FK constraint at
// insert time (see createAssetCategoryAttributeDef()'s P2003 handling
// below). Mirrors src/lib/purchase-orders.ts's vendorId handling.
categoryId: z
.string({ required_error: "categoryId is required and must be a valid UUID string" })
.uuid("categoryId is required and must be a valid UUID string"),
// Slug-style, e.g. "ram_gb" — only unique *within* a category (composite
// unique (categoryId, key) at the DB layer), not globally. Just requires
// non-empty here; the DB-level uniqueness is enforced by
// createAssetCategoryAttributeDef()'s P2002 handling below.
key: nonEmptyString("key is required and must be a non-empty string"),
label: nonEmptyString("label is required and must be a non-empty string"),
// Fixed 4-value set (see docs/asset-license-management.md's "Cập nhật
// 2026-08-02 (3)" section) — a DEV-defined closed technical set, mirrors
// src/lib/asset-categories.ts's valueGroup z.enum() pattern.
dataType: z.enum(["string", "number", "boolean", "date"], {
errorMap: () => ({
message: "dataType is required and must be one of 'string', 'number', 'boolean', 'date'",
}),
}),
required: z.boolean({
required_error: "required is required and must be a boolean",
invalid_type_error: "required is required and must be a boolean",
}),
sortOrder: z.number({
required_error: "sortOrder is required and must be an integer",
invalid_type_error: "sortOrder is required and must be an integer",
})
.int("sortOrder is required and must be an integer"),
});
/**
* Validates raw (untyped) asset-category-attribute-def-creation input.
* categoryId must be a valid UUID string, key/label must be non-empty
* strings, dataType must be one of the four fixed enum values, required
* must be a boolean, sortOrder must be an integer. Whether categoryId
* references a real AssetCategory, and whether (categoryId, key) is
* actually unique, are both left to Prisma's constraints at insert time
* (see createAssetCategoryAttributeDef() below).
*/
export function validateCreateAssetCategoryAttributeDefInput(input: {
categoryId?: unknown;
key?: unknown;
label?: unknown;
dataType?: unknown;
required?: unknown;
sortOrder?: unknown;
}): ValidationResult {
const result = createAssetCategoryAttributeDefSchema.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;
categoryId: string;
key: string;
label: string;
dataType: AssetAttributeDataType;
required: boolean;
sortOrder: number;
createdAt: Date;
}): AssetCategoryAttributeDefRecord {
return {
id: row.id,
category_id: row.categoryId,
key: row.key,
label: row.label,
data_type: row.dataType,
required: row.required,
sort_order: row.sortOrder,
created_at: row.createdAt.toISOString(),
};
}
export type CreateAssetCategoryAttributeDefResult =
| { ok: true; value: AssetCategoryAttributeDefRecord }
| { ok: false; error: string };
/**
* Creates an AssetCategoryAttributeDef. If (categoryId, key) collides with
* an existing row, Prisma raises P2002 (composite unique constraint
* violation) — caught here and translated into a clean {ok:false,error},
* same pattern as createAssetItem()'s serialNumber P2002 handling in
* src/lib/assets.ts. Note this is a *composite* uniqueness (unique within a
* category, not globally) — the same `key` under a *different* categoryId
* is expected to succeed, per docs/asset-license-management.md's "Cập nhật
* 2026-08-02 (3)" section. If categoryId doesn't reference an existing
* AssetCategory, Prisma raises P2003 (FK constraint violation) — caught the
* same way, mirrors src/lib/purchase-orders.ts's createPurchaseOrder() P2003
* handling.
*/
export async function createAssetCategoryAttributeDef(
input: CreateAssetCategoryAttributeDefInput
): Promise<CreateAssetCategoryAttributeDefResult> {
try {
const created = await prisma.assetCategoryAttributeDef.create({
data: {
categoryId: input.categoryId,
key: input.key,
label: input.label,
dataType: input.dataType,
required: input.required,
sortOrder: input.sortOrder,
},
});
return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
return { ok: false, error: "key already exists for this category" };
}
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
return { ok: false, error: "categoryId does not reference an existing asset category" };
}
throw error;
}
}
export async function listAssetCategoryAttributeDefs(): Promise<
AssetCategoryAttributeDefRecord[]
> {
const rows = await prisma.assetCategoryAttributeDef.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
+214
View File
@@ -0,0 +1,214 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
import type { AssetAttributeDataType } from "./asset-category-attribute-defs";
import { prisma } from "./prisma";
export interface AssetModelRecord {
id: string;
category_id: string;
manufacturer: string;
name: string;
specs: Record<string, unknown>;
created_at: string;
}
export interface CreateAssetModelInput {
categoryId: string;
manufacturer: string;
name: string;
specs: Record<string, unknown>;
}
export type ValidationResult =
| { ok: true; value: CreateAssetModelInput }
| { ok: false; error: string };
// Mirrors src/lib/asset-category-attribute-defs.ts's nonEmptyString helper.
function nonEmptyString(message: string) {
return z.string({ required_error: message, invalid_type_error: message }).refine(
(value) => value.trim() !== "",
{ message }
);
}
const createAssetModelSchema = z.object({
// FK to AssetCategory — only a UUID *format* check here, whether it
// references a real category (and whether that category is
// valueGroup=high_value) is left to createAssetModel()'s explicit lookup
// below — that's a business rule, not something pure Zod schema
// validation on this input alone can express. Mirrors
// src/lib/asset-category-attribute-defs.ts's categoryId handling.
categoryId: z
.string({ required_error: "categoryId is required and must be a valid UUID string" })
.uuid("categoryId is required and must be a valid UUID string"),
manufacturer: nonEmptyString("manufacturer is required and must be a non-empty string"),
name: nonEmptyString("name is required and must be a non-empty string"),
// Deliberately LOOSE at this stage — just "a plain JSON object" (no
// arrays, no primitives at the top level). The category-specific dynamic
// shape (which keys are allowed, which are required, and their types) is
// validated separately in createAssetModel() below, via a runtime Zod
// schema built from that category's AssetCategoryAttributeDef rows — it
// can't be done here because that requires a DB lookup keyed on
// categoryId, which this pure-input-shape schema doesn't have access to.
// Defaults to {} when omitted, matching the DB column default.
specs: z
.record(z.unknown(), {
invalid_type_error: "specs must be a JSON object",
})
.optional()
.default({}),
});
/**
* Validates raw (untyped) asset-model-creation input. categoryId must be a
* valid UUID string, manufacturer/name must be non-empty strings, specs
* must be a plain JSON object (or omitted, defaulting to {}). Whether
* categoryId references a real, high_value AssetCategory is checked in
* createAssetModel() below, not here — that requires a DB lookup.
*/
export function validateCreateAssetModelInput(input: {
categoryId?: unknown;
manufacturer?: unknown;
name?: unknown;
specs?: unknown;
}): ValidationResult {
const result = createAssetModelSchema.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;
categoryId: string;
manufacturer: string;
name: string;
specs: Prisma.JsonValue;
createdAt: Date;
}): AssetModelRecord {
return {
id: row.id,
category_id: row.categoryId,
manufacturer: row.manufacturer,
name: row.name,
specs: (row.specs as Record<string, unknown>) ?? {},
created_at: row.createdAt.toISOString(),
};
}
// Maps an AssetCategoryAttributeDef.dataType to the corresponding Zod leaf
// type, per docs/asset-license-management.md's "Cập nhật 2026-08-02 (3)"
// section: string -> z.string(), number -> z.number(), boolean ->
// z.boolean(), date -> z.coerce.date() (coerce so a JSON-transported date
// *string*, e.g. "2027-01-01", is accepted — JSON has no native Date type).
function zodTypeForDataType(dataType: AssetAttributeDataType): z.ZodTypeAny {
switch (dataType) {
case "string":
return z.string();
case "number":
return z.number();
case "boolean":
return z.boolean();
case "date":
return z.coerce.date();
}
}
/**
* Builds a Zod object schema AT RUNTIME from a category's
* AssetCategoryAttributeDef rows: one shape entry per attribute def (typed
* per zodTypeForDataType() above), optional when `required` is false via
* `.optional()`, and `.strict()` on the overall object so any `specs` key
* not defined for this category is rejected (no unknown keys allowed), per
* the doc section referenced above.
*/
function buildSpecsSchema(
attributeDefs: { key: string; dataType: AssetAttributeDataType; required: boolean }[]
): z.ZodTypeAny {
const shape: Record<string, z.ZodTypeAny> = {};
for (const def of attributeDefs) {
const leaf = zodTypeForDataType(def.dataType);
shape[def.key] = def.required ? leaf : leaf.optional();
}
return z.object(shape).strict();
}
export type CreateAssetModelResult =
| { ok: true; value: AssetModelRecord }
| { ok: false; error: string };
/**
* Creates an AssetModel. categoryId must reference a real AssetCategory
* (checked here via an explicit prisma.assetCategory.findUnique() lookup —
* not left to Prisma's P2003 FK-violation catch like other entities'
* simple FK checks, e.g. src/lib/asset-category-attribute-defs.ts's
* categoryId handling), because there's an additional business rule to
* enforce beyond "does this row exist": the referenced category's
* valueGroup must be `high_value` (per docs/asset-license-management.md's
* "Ràng buộc dữ liệu" section under "Cập nhật 2026-08-02 (2)") — a rule
* that can't be expressed as a Postgres FK constraint, so it can't be
* caught via a Prisma error code the way a plain missing-FK case can. If
* the category doesn't exist at all, or exists but is `low_value`, this
* returns a distinct {ok:false,error} for each case rather than a raw
* P2003/500.
*/
export async function createAssetModel(
input: CreateAssetModelInput
): Promise<CreateAssetModelResult> {
const category = await prisma.assetCategory.findUnique({
where: { id: input.categoryId },
});
if (!category) {
return { ok: false, error: "categoryId does not reference an existing asset category" };
}
if (category.valueGroup !== "high_value") {
return { ok: false, error: "categoryId must reference a category with valueGroup=high_value" };
}
// Dynamic specs validation, per docs/asset-license-management.md's
// "Cập nhật 2026-08-02 (3)" section: build a Zod schema at runtime from
// this category's AssetCategoryAttributeDef rows, and validate the
// incoming specs object against it. Deliberately done here (after the
// category existence/high_value checks above), not inside
// validateCreateAssetModelInput()'s static schema, because it needs a DB
// lookup keyed on categoryId.
const attributeDefs = await prisma.assetCategoryAttributeDef.findMany({
where: { categoryId: input.categoryId },
});
const specsSchema = buildSpecsSchema(attributeDefs);
const specsResult = specsSchema.safeParse(input.specs);
if (!specsResult.success) {
const message = specsResult.error.issues[0]?.message ?? "Invalid specs";
const path = specsResult.error.issues[0]?.path.join(".");
return {
ok: false,
error: path ? `specs.${path}: ${message}` : `specs: ${message}`,
};
}
const created = await prisma.assetModel.create({
data: {
categoryId: input.categoryId,
manufacturer: input.manufacturer,
name: input.name,
// input.specs is validated by Zod to be a plain JSON object
// (Record<string, unknown>), but Prisma's generated InputJsonValue
// type doesn't structurally accept an index-signature Record — cast
// is safe here since the Zod schema already guarantees JSON-shaped
// values.
specs: input.specs as Prisma.InputJsonValue,
},
});
return { ok: true, value: toRecord(created) };
}
export async function listAssetModels(): Promise<AssetModelRecord[]> {
const rows = await prisma.assetModel.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
+54 -14
View File
@@ -7,18 +7,20 @@ export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired";
export interface AssetItemRecord {
id: string;
serial_number: string;
model: string;
model_id: string;
purchase_date: string;
warranty_end_date: string | null;
status: AssetItemStatus;
purchase_order_id: string | null;
created_at: string;
}
export interface CreateAssetItemInput {
serialNumber: string;
model: string;
modelId: string;
purchaseDate: Date;
warrantyEndDate?: Date;
purchaseOrderId?: string | null;
}
export type ValidationResult =
@@ -45,24 +47,45 @@ function dateString(message: string) {
const createAssetItemSchema = z.object({
serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"),
model: nonEmptyString("model is required and must be a non-empty string"),
// Required FK to AssetModel — replaced the free-text `model` String (see
// schema.prisma / docs/asset-license-management.md's "Cập nhật 2026-08-02
// (2)" section). Only a UUID *format* check here, whether it references a
// real AssetModel is left to Prisma's own FK constraint at insert time
// (see createAssetItem()'s P2003 handling below). Mirrors
// src/lib/licenses.ts's vendorId handling.
modelId: z
.string({ required_error: "modelId is required and must be a valid UUID string" })
.uuid("modelId is required and must be a valid UUID string"),
purchaseDate: dateString("purchaseDate is required and must be a valid date string"),
warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(),
// FK to PurchaseOrder — nullable/optional, supply-side traceability (not
// every AssetItem goes through a formal PO). Only a UUID *format* check
// here, whether it references a real PurchaseOrder is left to Prisma's
// own FK constraint at insert time (see createAssetItem()'s P2003
// handling below). Mirrors src/lib/purchase-orders.ts's receivedById
// optional().nullable() pattern.
purchaseOrderId: z
.string({ invalid_type_error: "purchaseOrderId must be a valid UUID string" })
.uuid("purchaseOrderId must be a valid UUID string")
.optional()
.nullable(),
});
/**
* Validates raw (untyped) asset-item-creation input. serialNumber/model
* must be non-empty strings, purchaseDate must be a valid date string,
* warrantyEndDate is optional but must be a valid date string if present.
* status is not accepted here — new assets always start "in_stock" (same
* pattern as Request always starting "Draft"), later states are set via
* other operations, not at creation.
* Validates raw (untyped) asset-item-creation input. serialNumber must be a
* non-empty string, modelId must be a valid UUID string, purchaseDate must
* be a valid date string, warrantyEndDate is optional but must be a valid
* date string if present. purchaseOrderId is optional/nullable but must be
* a valid UUID if present. status is not accepted here — new assets always
* start "in_stock" (same pattern as Request always starting "Draft"), later
* states are set via other operations, not at creation.
*/
export function validateCreateAssetItemInput(input: {
serialNumber?: unknown;
model?: unknown;
modelId?: unknown;
purchaseDate?: unknown;
warrantyEndDate?: unknown;
purchaseOrderId?: unknown;
}): ValidationResult {
const result = createAssetItemSchema.safeParse(input);
if (!result.success) {
@@ -75,19 +98,21 @@ export function validateCreateAssetItemInput(input: {
function toRecord(row: {
id: string;
serialNumber: string;
model: string;
modelId: string;
purchaseDate: Date;
warrantyEndDate: Date | null;
status: AssetItemStatus;
purchaseOrderId: string | null;
createdAt: Date;
}): AssetItemRecord {
return {
id: row.id,
serial_number: row.serialNumber,
model: row.model,
model_id: row.modelId,
purchase_date: row.purchaseDate.toISOString(),
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
status: row.status,
purchase_order_id: row.purchaseOrderId,
created_at: row.createdAt.toISOString(),
};
}
@@ -102,7 +127,14 @@ export type CreateAssetItemResult =
* caught here and translated into the same `{ok:false,error}` shape as
* input validation failures, rather than letting a raw Prisma error leak
* to the API layer. Mirrors createEmployee()/createRequest()'s P2003
* handling for FK violations.
* handling for FK violations. Two separate FKs can fail here (modelId ->
* AssetModel, purchaseOrderId -> PurchaseOrder) — Prisma raises the same
* error code (P2003) for either, so the FK constraint name in
* `error.meta.field_name` is used to tell them apart and return a targeted
* message, rather than a raw Prisma error leaking to the API layer. Mirrors
* src/lib/licenses.ts's createLicense() / src/lib/purchase-orders.ts's
* createPurchaseOrder() pattern for disambiguating between two FKs on the
* same model.
*/
export async function createAssetItem(
input: CreateAssetItemInput
@@ -111,9 +143,10 @@ export async function createAssetItem(
const created = await prisma.assetItem.create({
data: {
serialNumber: input.serialNumber,
model: input.model,
modelId: input.modelId,
purchaseDate: input.purchaseDate,
warrantyEndDate: input.warrantyEndDate ?? null,
purchaseOrderId: input.purchaseOrderId ?? null,
},
});
return { ok: true, value: toRecord(created) };
@@ -121,6 +154,13 @@ export async function createAssetItem(
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
return { ok: false, error: "serialNumber already exists" };
}
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
if (fieldName.includes("purchase_order")) {
return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" };
}
return { ok: false, error: "modelId does not reference an existing asset model" };
}
throw error;
}
}
+533
View File
@@ -0,0 +1,533 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
export interface AssignmentRecord {
id: string;
employee_id: string;
asset_item_id: string | null;
license_id: string | null;
assigned_at: string;
returned_at: string | null;
signed_at: string | null;
created_at: string;
}
// assignedAt/returnedAt/signedAt are all deliberately NOT accepted from the
// client at creation time — same precedent as AssetItem.status always
// starting "in_stock" and License.seatsUsed always starting 0:
// assignedAt is a system-set "moment of assignment" timestamp
// (Prisma's @default(now()) on Assignment.assignedAt), returnedAt must be
// null on creation (an Assignment cannot be created already-returned — that
// would make no sense given the seat/status side-effects below), and
// signedAt (marking handover paperwork as signed) is not wired to any
// endpoint in this task at all — see feature-implementer report's "Out of
// scope" section. It stays null until a future "mark as signed" operation
// is built.
export interface CreateAssignmentInput {
employeeId: string;
assetItemId?: string | null;
licenseId?: string | null;
}
export type ValidationResult =
| { ok: true; value: CreateAssignmentInput }
| { ok: false; error: string };
// Mirrors src/lib/purchase-orders.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));
}
// 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: 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
// is NOT deferred entirely to Prisma's insert-time FK constraint —
// createAssignment() needs to read the referenced row anyway (to check
// AssetItem.status / License.seatsUsed business rules), so existence is
// checked explicitly inside the transaction instead. Only format
// validation happens here at the Zod layer.
assetItemId: z
.string({ invalid_type_error: "assetItemId must be a valid UUID string" })
.uuid("assetItemId must be a valid UUID string")
.optional()
.nullable(),
licenseId: z
.string({ invalid_type_error: "licenseId must be a valid UUID string" })
.uuid("licenseId must be a valid UUID string")
.optional()
.nullable(),
})
.refine(
(value) => {
const hasAssetItem = value.assetItemId !== undefined && value.assetItemId !== null;
const hasLicense = value.licenseId !== undefined && value.licenseId !== null;
// XOR: exactly one of the two must be set.
return hasAssetItem !== hasLicense;
},
{
message: "Exactly one of assetItemId or licenseId must be set (not both, not neither)",
// Attach the error to both fields so it surfaces regardless of which
// one the caller looks at first — mirrors
// src/lib/purchase-orders.ts's receivedAt/receivedById co-nullability
// refine, which attaches to a single path for the same reason.
path: ["assetItemId"],
}
);
/**
* Validates raw (untyped) assignment-creation input. employeeId must be a
* valid UUID string. Exactly one of assetItemId/licenseId must be a valid
* UUID string (XOR — both set or both unset is rejected). assignedAt,
* returnedAt, and signedAt are not accepted here — see CreateAssignmentInput's
* doc comment for why.
*/
export function validateCreateAssignmentInput(input: {
employeeId?: unknown;
assetItemId?: unknown;
licenseId?: unknown;
}): ValidationResult {
const result = createAssignmentSchema.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 };
}
const returnAssignmentSchema = z.object({
// Optional — defaults to "now" at the time returnAssignment() runs if not
// provided (mirrors Assignment.assignedAt's own default-now behavior at
// creation). Accepting an explicit value lets a caller backdate a return
// (e.g. recording a return that was physically handled yesterday but only
// entered into the system today) — same spirit as PurchaseOrder accepting
// an explicit receivedAt rather than always stamping "now".
returnedAt: dateString("returnedAt must be a valid date string").optional(),
});
export type ReturnValidationResult =
| { ok: true; value: { returnedAt?: Date } }
| { ok: false; error: string };
/**
* Validates raw (untyped) assignment-return input. returnedAt is optional
* but must be a valid date string if present.
*/
export function validateReturnAssignmentInput(input: {
returnedAt?: unknown;
}): ReturnValidationResult {
const result = returnAssignmentSchema.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 };
}
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;
assetItemId: string | null;
licenseId: string | null;
assignedAt: Date;
returnedAt: Date | null;
signedAt: Date | null;
createdAt: Date;
}): AssignmentRecord {
return {
id: row.id,
employee_id: row.employeeId,
asset_item_id: row.assetItemId,
license_id: row.licenseId,
assigned_at: row.assignedAt.toISOString(),
returned_at: row.returnedAt ? row.returnedAt.toISOString() : null,
signed_at: row.signedAt ? row.signedAt.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.
class AssignmentBusinessError extends Error {
constructor(message: string) {
super(message);
this.name = "AssignmentBusinessError";
}
}
class AssignmentNotFoundError extends Error {
constructor() {
super("Assignment not found");
this.name = "AssignmentNotFoundError";
}
}
export type CreateAssignmentResult =
| { ok: true; value: AssignmentRecord }
| { ok: false; error: string };
/**
* Creates an Assignment (assigns an AssetItem or a License seat to an
* Employee), enforcing every business rule from docs/
* asset-license-management.md's "## Assignment (chốt 2026-08-02)" section
* inside a single `prisma.$transaction()`:
*
* - AssetItem branch: rejects if the AssetItem doesn't exist, rejects if its
* current status isn't "in_stock" (stakeholder-confirmed precondition —
* see the doc's "chốt 2026-08-02 (2)" note), rejects if there's already an
* ACTIVE Assignment (returnedAt IS NULL) for it (explicit
* belt-and-suspenders invariant per the doc, on top of the status check),
* then atomically flips AssetItem.status to "assigned".
* - License branch: rejects if the License doesn't exist, rejects if
* seatsUsed >= seatsTotal (no overbooking), then atomically increments
* seatsUsed by 1.
*
* Race-condition note (read this before touching the transaction body):
* both branches use a two-step "read for validation, then conditional
* `updateMany` with a WHERE guard" pattern instead of a plain read-then-write
* (`findUnique` then `update`). The WHERE-guarded `updateMany` — e.g.
* `updateMany({ where: { id, status: "in_stock" }, data: { status:
* "assigned" } })` — is the actual race-closing mechanism: Postgres
* evaluates and applies that as a single atomic UPDATE statement, taking a
* row lock for its duration. Two concurrent transactions racing on the same
* AssetItem/License row serialize on that lock — the first to commit wins,
* and the second's WHERE clause is re-evaluated against the now-committed
* row and no longer matches, so its `updateMany` returns `count: 0`, which
* this function treats as a "lost the race" rejection. This is why the
* `findUnique` calls right before each `updateMany` are NOT the
* race-closing check by themselves (there's a real TOCTOU gap between a
* `findUnique` and a later `update` in isolation) — they only exist to
* produce a specific, friendly error message (not-found vs. wrong-status vs.
* no-seats) for the *common* (non-racing) case; the `updateMany`'s `count`
* is always re-checked afterwards and is what actually gets trusted.
* seatsTotal itself is read once via that same `findUnique` and reused in
* the `updateMany`'s WHERE clause (`seatsUsed: { lt: seatsTotal }`) — this
* is safe because nothing in this codebase ever mutates seatsTotal after
* creation (no update endpoint exists for it), so it can't go stale between
* the read and the write within the same transaction.
*
* The explicit "no existing active Assignment for this AssetItem" check
* (`tx.assignment.findFirst({ where: { assetItemId, returnedAt: null } })`)
* is intentionally layered BEFORE the atomic status update, as defense in
* depth against AssetItem.status/Assignment-row drift (e.g. manual DB
* intervention) — under normal operation this codebase always keeps
* `status === "assigned"` in exact sync with "has an active Assignment", so
* the atomic status-guarded `updateMany` above is what actually closes the
* concurrency race for the AssetItem branch; this check does not need its
* own atomic guard on top because the transaction's row lock (from the
* subsequent `updateMany`) is what makes the overall operation safe, not
* this read on its own.
*/
export async function createAssignment(
input: CreateAssignmentInput
): Promise<CreateAssignmentResult> {
try {
const created = await prisma.$transaction(async (tx) => {
if (input.assetItemId) {
const assetItem = await tx.assetItem.findUnique({ where: { id: input.assetItemId } });
if (!assetItem) {
throw new AssignmentBusinessError(
"assetItemId does not reference an existing asset item"
);
}
if (assetItem.status !== "in_stock") {
throw new AssignmentBusinessError(
`assetItemId refers to an AssetItem that is not in_stock (current status: ${assetItem.status})`
);
}
const existingActive = await tx.assignment.findFirst({
where: { assetItemId: input.assetItemId, returnedAt: null },
});
if (existingActive) {
throw new AssignmentBusinessError(
"assetItemId already has an active assignment (returnedAt IS NULL)"
);
}
// Atomic race-closing gate — see this function's doc comment.
const statusUpdate = await tx.assetItem.updateMany({
where: { id: input.assetItemId, status: "in_stock" },
data: { status: "assigned" },
});
if (statusUpdate.count === 0) {
throw new AssignmentBusinessError(
"assetItemId refers to an AssetItem that is not in_stock (lost a race to another concurrent assignment)"
);
}
}
if (input.licenseId) {
const license = await tx.license.findUnique({ where: { id: input.licenseId } });
if (!license) {
throw new AssignmentBusinessError("licenseId does not reference an existing license");
}
if (license.seatsUsed >= license.seatsTotal) {
throw new AssignmentBusinessError(
"licenseId has no seats remaining (seatsUsed >= seatsTotal)"
);
}
// Atomic race-closing gate — see this function's doc comment.
const seatUpdate = await tx.license.updateMany({
where: { id: input.licenseId, seatsUsed: { lt: license.seatsTotal } },
data: { seatsUsed: { increment: 1 } },
});
if (seatUpdate.count === 0) {
throw new AssignmentBusinessError(
"licenseId has no seats remaining (lost a race to another concurrent assignment)"
);
}
}
return tx.assignment.create({
data: {
employeeId: input.employeeId,
assetItemId: input.assetItemId ?? null,
licenseId: input.licenseId ?? null,
},
});
});
return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof AssignmentBusinessError) {
return { ok: false, error: error.message };
}
// Only employeeId can still fail with a raw FK violation here —
// assetItemId/licenseId existence is already fully resolved above
// (found-or-rejected before the transaction ever reaches
// tx.assignment.create()).
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
return { ok: false, error: "employeeId does not reference an existing employee" };
}
throw error;
}
}
export async function listAssignments(): Promise<AssignmentRecord[]> {
const rows = await prisma.assignment.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
export type ReturnAssignmentResult =
| { ok: true; value: AssignmentRecord }
| { ok: false; status: 404 | 400; error: string };
/**
* Marks an Assignment as returned (sets returnedAt), and reverses its
* side-effects from createAssignment() inside the same transaction:
* - If assetItemId is set: flips AssetItem.status back to "in_stock" —
* always, regardless of any RMA/repair path (there isn't one built yet,
* per the task's instructions; see feature-implementer report).
* - If licenseId is set: atomically decrements License.seatsUsed by 1.
*
* Race-condition note: double-return is closed the same way as
* createAssignment()'s races — an atomic, WHERE-guarded `updateMany`
* (`where: { id, returnedAt: null }`) is the actual gate. Two concurrent
* PATCH-return calls against the same Assignment 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 returned". The initial `findUnique` (used to
* read assetItemId/licenseId for the AssetItem/License side-effects, and to
* distinguish "not found" from "already returned" in the common case) is
* NOT itself the race-closing check, same caveat as createAssignment()'s
* doc comment. Decrementing seatsUsed does not need its own conditional
* `updateMany` guard the way incrementing did on the assign side — a bare
* `{ decrement: 1 }` is a single atomic UPDATE with no upper bound to
* enforce (unlike assign's seatsUsed < seatsTotal check), so a plain
* `update()` is race-safe on its own here.
*/
export async function returnAssignment(
id: string,
input: { returnedAt?: Date }
): Promise<ReturnAssignmentResult> {
try {
const updated = await prisma.$transaction(async (tx) => {
const assignment = await tx.assignment.findUnique({ where: { id } });
if (!assignment) {
throw new AssignmentNotFoundError();
}
const returnedAt = input.returnedAt ?? new Date();
// Atomic race-closing gate — see this function's doc comment. Also
// doubles as the "already returned" rejection in the non-racing case,
// since `assignment.returnedAt !== null` implies this WHERE clause
// won't match either.
const claim = await tx.assignment.updateMany({
where: { id, returnedAt: null },
data: { returnedAt },
});
if (claim.count === 0) {
throw new AssignmentBusinessError("Assignment has already been returned");
}
if (assignment.assetItemId) {
await tx.assetItem.update({
where: { id: assignment.assetItemId },
data: { status: "in_stock" },
});
}
if (assignment.licenseId) {
await tx.license.update({
where: { id: assignment.licenseId },
data: { seatsUsed: { decrement: 1 } },
});
}
return tx.assignment.findUniqueOrThrow({ where: { id } });
});
return { ok: true, value: toRecord(updated) };
} 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 };
}
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;
}
}
+64 -19
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
export type LicenseType = "perpetual" | "subscription";
@@ -6,20 +7,22 @@ export type LicenseType = "perpetual" | "subscription";
export interface LicenseRecord {
id: string;
name: string;
vendor: string;
vendor_id: string;
seats_total: number;
seats_used: number;
expiry_date: string | null;
license_type: LicenseType;
purchase_order_id: string | null;
created_at: string;
}
export interface CreateLicenseInput {
name: string;
vendor: string;
vendorId: string;
seatsTotal: number;
expiryDate?: Date | null;
licenseType: LicenseType;
purchaseOrderId?: string | null;
}
export type ValidationResult =
@@ -46,7 +49,14 @@ function dateString(message: string) {
const createLicenseSchema = z.object({
name: nonEmptyString("name is required and must be a non-empty string"),
vendor: nonEmptyString("vendor is required and must be a non-empty string"),
// Now a real FK to Vendor (see schema.prisma) — only a UUID *format*
// check here — whether it references a real vendor is left to Prisma's
// own FK constraint at insert time (see createLicense()'s P2003 handling
// below), rather than an extra pre-check query. Mirrors the same pattern
// used for Request.employeeId in src/lib/requests.ts.
vendorId: z
.string({ required_error: "vendorId is required and must be a valid UUID string" })
.uuid("vendorId is required and must be a valid UUID string"),
seatsTotal: z
.number({ required_error: "seatsTotal is required and must be a positive integer", invalid_type_error: "seatsTotal is required and must be a positive integer" })
.int({ message: "seatsTotal is required and must be a positive integer" })
@@ -57,23 +67,36 @@ const createLicenseSchema = z.object({
message: "licenseType is required and must be either 'perpetual' or 'subscription'",
}),
}),
// FK to PurchaseOrder — nullable/optional, supply-side traceability (not
// every License goes through a formal PO). Only a UUID *format* check
// here, whether it references a real PurchaseOrder is left to Prisma's
// own FK constraint at insert time (see createLicense()'s P2003 handling
// below). Mirrors src/lib/assets.ts's purchaseOrderId
// optional().nullable() pattern exactly.
purchaseOrderId: z
.string({ invalid_type_error: "purchaseOrderId must be a valid UUID string" })
.uuid("purchaseOrderId must be a valid UUID string")
.optional()
.nullable(),
});
/**
* Validates raw (untyped) license-creation input. name/vendor must be
* non-empty strings, seatsTotal must be a positive integer, expiryDate is
* optional/nullable but must be a valid date string if present, licenseType
* must be one of the two enum values. seatsUsed is not accepted here — new
* licenses always start at 0 (same pattern as AssetItem always starting
* "in_stock"), seats are only ever consumed via other operations (once
* Assignment exists), not at creation.
* Validates raw (untyped) license-creation input. name must be a non-empty
* string, vendorId must be a valid UUID string, seatsTotal must be a
* positive integer, expiryDate is optional/nullable but must be a valid
* date string if present, licenseType must be one of the two enum values.
* seatsUsed is not accepted here — new licenses always start at 0 (same
* pattern as AssetItem always starting "in_stock"), seats are only ever
* consumed via other operations (once Assignment exists), not at creation.
* purchaseOrderId is optional/nullable but must be a valid UUID if present.
*/
export function validateCreateLicenseInput(input: {
name?: unknown;
vendor?: unknown;
vendorId?: unknown;
seatsTotal?: unknown;
expiryDate?: unknown;
licenseType?: unknown;
purchaseOrderId?: unknown;
}): ValidationResult {
const result = createLicenseSchema.safeParse(input);
if (!result.success) {
@@ -86,41 +109,63 @@ export function validateCreateLicenseInput(input: {
function toRecord(row: {
id: string;
name: string;
vendor: string;
vendorId: string;
seatsTotal: number;
seatsUsed: number;
expiryDate: Date | null;
licenseType: LicenseType;
purchaseOrderId: string | null;
createdAt: Date;
}): LicenseRecord {
return {
id: row.id,
name: row.name,
vendor: row.vendor,
vendor_id: row.vendorId,
seats_total: row.seatsTotal,
seats_used: row.seatsUsed,
expiry_date: row.expiryDate ? row.expiryDate.toISOString() : null,
license_type: row.licenseType,
purchase_order_id: row.purchaseOrderId,
created_at: row.createdAt.toISOString(),
};
}
export type CreateLicenseResult =
| { ok: true; value: LicenseRecord }
| { ok: false; error: string };
/**
* Creates a License. No unique constraint exists on `name` (unlike
* AssetItem.serialNumber), so unlike createAssetItem() there's no known
* Prisma error code to catch/translate here.
* Creates a License. Two separate FKs can fail here (vendorId -> Vendor,
* purchaseOrderId -> PurchaseOrder) — Prisma raises the same error code
* (P2003) for either, so the FK constraint name in `error.meta.field_name`
* is used to tell them apart and return a targeted message, rather than a
* raw Prisma error leaking to the API layer. Mirrors
* src/lib/purchase-orders.ts's createPurchaseOrder() pattern for
* disambiguating between two FKs on the same model.
*/
export async function createLicense(input: CreateLicenseInput): Promise<LicenseRecord> {
export async function createLicense(input: CreateLicenseInput): Promise<CreateLicenseResult> {
try {
const created = await prisma.license.create({
data: {
name: input.name,
vendor: input.vendor,
vendorId: input.vendorId,
seatsTotal: input.seatsTotal,
expiryDate: input.expiryDate ?? null,
licenseType: input.licenseType,
purchaseOrderId: input.purchaseOrderId ?? null,
},
});
return toRecord(created);
return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
if (fieldName.includes("purchase_order")) {
return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" };
}
return { ok: false, error: "vendorId does not reference an existing vendor" };
}
throw error;
}
}
export async function listLicenses(): Promise<LicenseRecord[]> {
+438
View File
@@ -0,0 +1,438 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
import type { AssetItemStatus } from "./assets";
export type MaintenanceType = "repair" | "warranty" | "preventive";
export interface MaintenanceRecordRecord {
id: string;
asset_item_id: string;
type: MaintenanceType;
vendor_id: string | null;
issue_description: string;
resolution_notes: string | null;
start_date: string;
end_date: string | null;
created_at: string;
}
// resolutionNotes/endDate are deliberately NOT accepted from the client at
// creation time — same precedent as Assignment.returnedAt not being
// accepted at creation (see src/lib/assignments.ts's CreateAssignmentInput
// doc comment): a MaintenanceRecord always opens with endDate/
// resolutionNotes null, only closeMaintenanceRecord() sets them later.
export interface CreateMaintenanceRecordInput {
assetItemId: string;
type: MaintenanceType;
vendorId?: string | null;
issueDescription: string;
startDate: Date;
}
export type ValidationResult =
| { ok: true; value: CreateMaintenanceRecordInput }
| { ok: false; error: string };
// Mirrors src/lib/assets.ts's nonEmptyString helper.
function nonEmptyString(message: string) {
return z.string({ required_error: message, invalid_type_error: message }).refine(
(value) => value.trim() !== "",
{ message }
);
}
// Mirrors src/lib/assignments.ts's dateString helper — accepts any string
// Date.parse() can handle and transforms it into a Date for Prisma.
function dateString(message: string) {
return z
.string({ required_error: message, invalid_type_error: message })
.refine((value) => !Number.isNaN(Date.parse(value)), { message })
.transform((value) => new Date(value));
}
const createMaintenanceRecordSchema = z.object({
// FK to AssetItem — only a UUID *format* check here. Unlike a plain
// supply-side FK (e.g. AssetItem.purchaseOrderId), existence is checked
// explicitly inside the transaction below (not deferred entirely to
// Prisma's insert-time FK constraint), because createMaintenanceRecord()
// needs to read the referenced row anyway to enforce the status/active-
// assignment/already-open business rules. Mirrors
// src/lib/assignments.ts's assetItemId handling.
assetItemId: z
.string({ required_error: "assetItemId is required and must be a valid UUID string" })
.uuid("assetItemId is required and must be a valid UUID string"),
// Mirrors src/lib/requests.ts's type z.enum() pattern.
type: z.enum(["repair", "warranty", "preventive"], {
errorMap: () => ({
message: "type is required and must be one of 'repair', 'warranty', 'preventive'",
}),
}),
// FK to Vendor — nullable/optional, null means an internal repair with no
// outside vendor involved (see schema.prisma's MaintenanceRecord comment).
// Only a UUID *format* check here, whether it references a real vendor is
// left to Prisma's own FK constraint at insert time (see
// createMaintenanceRecord()'s P2003 handling below). Mirrors
// src/lib/licenses.ts's vendorId handling (except nullable here).
vendorId: z
.string({ invalid_type_error: "vendorId must be a valid UUID string" })
.uuid("vendorId must be a valid UUID string")
.optional()
.nullable(),
issueDescription: nonEmptyString(
"issueDescription is required and must be a non-empty string"
),
startDate: dateString("startDate is required and must be a valid date string"),
});
/**
* Validates raw (untyped) maintenance-record-creation input. assetItemId
* must be a valid UUID string, type must be one of the three enum values,
* vendorId is optional/nullable but must be a valid UUID string if present,
* issueDescription must be a non-empty string, startDate must be a valid
* date string. resolutionNotes/endDate are not accepted here — see
* CreateMaintenanceRecordInput's doc comment for why.
*/
export function validateCreateMaintenanceRecordInput(input: {
assetItemId?: unknown;
type?: unknown;
vendorId?: unknown;
issueDescription?: unknown;
startDate?: unknown;
}): ValidationResult {
const result = createMaintenanceRecordSchema.safeParse(input);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid input";
return { ok: false, error: message };
}
return { ok: true, value: result.data };
}
// Closed set of the 2 AssetItemStatus values a maintenance-record close is
// allowed to land on — deliberately NOT the full 4-value AssetItemStatus
// enum (see closeMaintenanceRecordSchema's targetStatus comment below for
// why "assigned"/"repair" are excluded).
const closeableTargetStatusValues = ["in_stock", "retired"] as const;
const closeMaintenanceRecordSchema = z.object({
endDate: dateString("endDate is required and must be a valid date string"),
// Nullable/optional — a closed record doesn't strictly require notes (e.g.
// a warranty swap with nothing more to say than "resolved"). Mirrors the
// optional().nullable() shape used for other nullable-in-DB text/FK
// fields throughout this codebase (e.g. AssetItem.purchaseOrderId).
resolutionNotes: z
.string({ invalid_type_error: "resolutionNotes must be a string" })
.optional()
.nullable(),
// Required — deliberately NOT defaulted to "in_stock", per docs/
// asset-license-management.md's "MaintenanceRecord" section: "không tự
// động mặc định về in_stock, vì sửa không phải lúc nào cũng thành công."
// Restricted to "in_stock" | "retired" only (NOT the full 4-value
// AssetItemStatus enum) — those are the only two outcomes a repair can
// actually resolve to (fixed → back to stock, unfixable → write off).
// "assigned" is deliberately rejected: setting AssetItem.status to
// "assigned" here would NOT create a corresponding Assignment row, so
// Assignment (the source of truth for "who currently holds what") and
// AssetItem.status would go out of sync with no way back — createAssignment()
// only accepts a "in_stock" AssetItem, and there is no standalone
// AssetItem-status-edit endpoint to undo it by hand. "repair" is
// rejected too: it would leave the record's own AssetItem "in repair"
// despite this exact call being the one that closes out its maintenance
// record — a closed record whose item is still marked "repair" is a
// contradiction, and it would also make the item wrongly ineligible for
// a new maintenance record's own "no other open record" precondition to
// ever apply cleanly (a new one could still be opened as a *different*
// record row while the old one sits closed but the item is still
// "repair").
targetStatus: z.enum(closeableTargetStatusValues, {
errorMap: () => ({
message: "targetStatus is required and must be one of 'in_stock', 'retired'",
}),
}),
});
export type CloseMaintenanceRecordInput = {
endDate: Date;
resolutionNotes?: string | null;
targetStatus: AssetItemStatus;
};
export type CloseValidationResult =
| { ok: true; value: CloseMaintenanceRecordInput }
| { ok: false; error: string };
/**
* Validates raw (untyped) maintenance-record-close input. endDate must be a
* valid date string, resolutionNotes is optional/nullable but must be a
* string if present, targetStatus is required and must be a valid
* AssetItemStatus value. endDate >= startDate cannot be checked here (it
* needs the already-saved record's startDate from the DB) — that's enforced
* inside closeMaintenanceRecord() itself.
*/
export function validateCloseMaintenanceRecordInput(input: {
endDate?: unknown;
resolutionNotes?: unknown;
targetStatus?: unknown;
}): CloseValidationResult {
const result = closeMaintenanceRecordSchema.safeParse(input);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid input";
return { ok: false, error: message };
}
return { ok: true, value: result.data };
}
function toRecord(row: {
id: string;
assetItemId: string;
type: MaintenanceType;
vendorId: string | null;
issueDescription: string;
resolutionNotes: string | null;
startDate: Date;
endDate: Date | null;
createdAt: Date;
}): MaintenanceRecordRecord {
return {
id: row.id,
asset_item_id: row.assetItemId,
type: row.type,
vendor_id: row.vendorId,
issue_description: row.issueDescription,
resolution_notes: row.resolutionNotes,
start_date: row.startDate.toISOString(),
end_date: row.endDate ? row.endDate.toISOString() : null,
created_at: row.createdAt.toISOString(),
};
}
// Internal error type used to carry a clean, already-user-facing message out
// of the `prisma.$transaction()` callback below, distinguishing
// application-level business-rule rejections (400) from unexpected/infra
// errors (rethrown as-is). Not exported — callers only ever see the
// {ok,error} result shape. Mirrors src/lib/assignments.ts's
// AssignmentBusinessError.
class MaintenanceRecordBusinessError extends Error {
constructor(message: string) {
super(message);
this.name = "MaintenanceRecordBusinessError";
}
}
class MaintenanceRecordNotFoundError extends Error {
constructor() {
super("MaintenanceRecord not found");
this.name = "MaintenanceRecordNotFoundError";
}
}
export type CreateMaintenanceRecordResult =
| { ok: true; value: MaintenanceRecordRecord }
| { ok: false; error: string };
/**
* Opens a MaintenanceRecord for an AssetItem, enforcing every business rule
* from docs/asset-license-management.md's "## MaintenanceRecord (spec BA +
* stakeholder, chốt 2026-08-04)" section inside a single
* `prisma.$transaction()`:
*
* - Rejects if the AssetItem doesn't exist.
* - Rejects if the AssetItem is already "retired" (thanh lý thì gửi sửa
* không còn ý nghĩa).
* - Rejects if the AssetItem has an ACTIVE Assignment (returnedAt IS NULL)
* — must be returned first, mirrors createAssignment()'s own
* status precondition check.
* - Rejects if the AssetItem already has another OPEN MaintenanceRecord
* (endDate IS NULL) — at most one open record per item at a time (a
* physical item can't be in two repair places at once).
* - Atomically flips AssetItem.status to "repair".
*
* Race-condition note: mirrors src/lib/assignments.ts's createAssignment()
* pattern — the AssetItem.status flip uses a WHERE-guarded `updateMany`
* (`where: { id: assetItemId, status: "in_stock" }`) rather than a
* plain read-then-write. Pinning the exact required prior value (not
* excluding "retired") matters: an excluding guard would still match an
* item another concurrent transaction just flipped to "assigned" or
* "repair", letting two concurrent opens (or an open racing an Assignment
* create) both win. That `updateMany`'s `count` is the actual
* race-closing gate: two concurrent opens racing on the same AssetItem
* serialize on Postgres's row lock for that UPDATE, the first to commit
* wins, and the second's WHERE clause no longer matches (status is no
* longer what it read), so its `updateMany` returns `count: 0`, treated
* here as "lost the race". The `findUnique` above it is NOT itself the
* race-closing check (same TOCTOU caveat as createAssignment()'s doc
* comment) — it only produces a specific, friendly error message
* (not-found vs. retired vs. active-assignment vs. already-open) for the
* common (non-racing) case.
*/
export async function createMaintenanceRecord(
input: CreateMaintenanceRecordInput
): Promise<CreateMaintenanceRecordResult> {
try {
const created = await prisma.$transaction(async (tx) => {
const assetItem = await tx.assetItem.findUnique({ where: { id: input.assetItemId } });
if (!assetItem) {
throw new MaintenanceRecordBusinessError(
"assetItemId does not reference an existing asset item"
);
}
if (assetItem.status === "retired") {
throw new MaintenanceRecordBusinessError(
"assetItemId refers to an AssetItem that is retired and cannot be sent for maintenance"
);
}
const activeAssignment = await tx.assignment.findFirst({
where: { assetItemId: input.assetItemId, returnedAt: null },
});
if (activeAssignment) {
throw new MaintenanceRecordBusinessError(
"assetItemId has an active assignment (returnedAt IS NULL) — return it before creating a maintenance record"
);
}
const openRecord = await tx.maintenanceRecord.findFirst({
where: { assetItemId: input.assetItemId, endDate: null },
});
if (openRecord) {
throw new MaintenanceRecordBusinessError(
"assetItemId already has an open maintenance record (endDate IS NULL)"
);
}
// Atomic race-closing gate — see this function's doc comment. Pins the
// exact prior value ("in_stock") rather than excluding a value
// ("not retired"), mirroring createAssignment()'s own status guard
// (src/lib/assignments.ts) — the excluding form would still match an
// AssetItem another concurrent transaction just flipped to "assigned"
// or "repair" (neither equals "retired"), letting two opens (or an
// open racing an Assignment create) both win.
const statusUpdate = await tx.assetItem.updateMany({
where: { id: input.assetItemId, status: "in_stock" },
data: { status: "repair" },
});
if (statusUpdate.count === 0) {
throw new MaintenanceRecordBusinessError(
"assetItemId refers to an AssetItem that is not in_stock (lost a race to a concurrent status change)"
);
}
return tx.maintenanceRecord.create({
data: {
assetItemId: input.assetItemId,
type: input.type,
vendorId: input.vendorId ?? null,
issueDescription: input.issueDescription,
startDate: input.startDate,
endDate: null,
resolutionNotes: null,
},
});
});
return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof MaintenanceRecordBusinessError) {
return { ok: false, error: error.message };
}
// Only vendorId can still fail with a raw FK violation here —
// assetItemId existence is already fully resolved above (found-or-
// rejected before the transaction ever reaches
// tx.maintenanceRecord.create()).
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
return { ok: false, error: "vendorId does not reference an existing vendor" };
}
throw error;
}
}
export async function listMaintenanceRecords(): Promise<MaintenanceRecordRecord[]> {
const rows = await prisma.maintenanceRecord.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
export type CloseMaintenanceRecordResult =
| { ok: true; value: MaintenanceRecordRecord }
| { ok: false; status: 404 | 400; error: string };
/**
* Closes a MaintenanceRecord (sets endDate + resolutionNotes) and applies
* the caller-chosen `targetStatus` to its AssetItem, inside a single
* `prisma.$transaction()` — docs/asset-license-management.md's
* "MaintenanceRecord" section, "Đóng MaintenanceRecord" bullet.
* targetStatus is never defaulted (e.g. to "in_stock") — repair doesn't
* always succeed, the caller must say explicitly what state the item ends
* up in (e.g. "in_stock" if fixed, "retired" if the repair failed).
*
* Race-condition note: mirrors src/lib/assignments.ts's
* returnAssignment()/transferAssignment() pattern exactly — double-close is
* closed by a single atomic, WHERE-guarded `updateMany`
* (`where: { id, endDate: null }`) that both checks AND writes endDate/
* resolutionNotes in one statement, rather than a separate guard check
* followed by a plain `update()`. Two concurrent close calls against the
* same MaintenanceRecord race on that row's UPDATE lock; only the first to
* commit gets `count: 1`, the second gets `count: 0` and is rejected as
* "already closed". The initial `findUnique` (used to read startDate for
* the endDate>=startDate check, and to distinguish "not found" from
* "already closed" in the common case) is NOT itself the race-closing check
* — same caveat as the Assignment lib's doc comments. The endDate>=startDate
* business-rule check is deliberately done BEFORE the atomic updateMany
* (using the record read by the preceding findUnique) since it only needs
* the record's already-immutable startDate, not anything the race could
* change. The subsequent AssetItem.status write does not need its own
* atomic guard — same rationale as returnAssignment()'s plain
* `tx.assetItem.update()` for its own status flip: targetStatus is applied
* unconditionally, there's no upper/lower bound to race on the way there
* was for License.seatsUsed at create time.
*/
export async function closeMaintenanceRecord(
id: string,
input: CloseMaintenanceRecordInput
): Promise<CloseMaintenanceRecordResult> {
try {
const updated = await prisma.$transaction(async (tx) => {
const record = await tx.maintenanceRecord.findUnique({ where: { id } });
if (!record) {
throw new MaintenanceRecordNotFoundError();
}
if (input.endDate.getTime() < record.startDate.getTime()) {
throw new MaintenanceRecordBusinessError("endDate must be on or after startDate");
}
// Atomic race-closing gate — see this function's doc comment. Also
// doubles as the "already closed" rejection in the non-racing case,
// since `record.endDate !== null` implies this WHERE clause won't
// match either.
const claim = await tx.maintenanceRecord.updateMany({
where: { id, endDate: null },
data: {
endDate: input.endDate,
resolutionNotes: input.resolutionNotes ?? null,
},
});
if (claim.count === 0) {
throw new MaintenanceRecordBusinessError("MaintenanceRecord has already been closed");
}
await tx.assetItem.update({
where: { id: record.assetItemId },
data: { status: input.targetStatus },
});
return tx.maintenanceRecord.findUniqueOrThrow({ where: { id } });
});
return { ok: true, value: toRecord(updated) };
} catch (error) {
if (error instanceof MaintenanceRecordNotFoundError) {
return { ok: false, status: 404, error: "MaintenanceRecord not found" };
}
if (error instanceof MaintenanceRecordBusinessError) {
return { ok: false, status: 400, error: error.message };
}
throw error;
}
}
+161
View File
@@ -0,0 +1,161 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
export interface PurchaseOrderRecord {
id: string;
vendor_id: string;
order_date: string;
expected_date: string;
received_at: string | null;
received_by: string | null;
created_at: string;
}
export interface CreatePurchaseOrderInput {
vendorId: string;
orderDate: Date;
expectedDate: Date;
receivedAt?: Date | null;
receivedById?: string | null;
}
export type ValidationResult =
| { ok: true; value: CreatePurchaseOrderInput }
| { ok: false; error: string };
// Mirrors src/lib/licenses.ts's dateString helper — accepts any string
// Date.parse() can handle and transforms it into a Date for Prisma. Used
// here for orderDate/expectedDate/receivedAt, which are all Timestamptz
// (not just Date) columns, so datetime strings (not just "2026-07-31") are
// also accepted.
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 createPurchaseOrderSchema = z
.object({
// FK to Vendor — only a UUID *format* check here, whether it references
// a real vendor is left to Prisma's own FK constraint at insert time
// (see createPurchaseOrder()'s P2003 handling below). Mirrors
// src/lib/licenses.ts's vendorId handling.
vendorId: z
.string({ required_error: "vendorId is required and must be a valid UUID string" })
.uuid("vendorId is required and must be a valid UUID string"),
orderDate: dateString("orderDate is required and must be a valid date string"),
expectedDate: dateString("expectedDate is required and must be a valid date string"),
// Both nullable — null means "not yet received". Co-nullability with
// receivedById is enforced by the .refine() below, not here.
receivedAt: dateString("receivedAt must be a valid date string").optional().nullable(),
receivedById: z
.string({ invalid_type_error: "receivedById must be a valid UUID string" })
.uuid("receivedById must be a valid UUID string")
.optional()
.nullable(),
})
.refine(
(value) => {
const hasReceivedAt = value.receivedAt !== undefined && value.receivedAt !== null;
const hasReceivedById = value.receivedById !== undefined && value.receivedById !== null;
return hasReceivedAt === hasReceivedById;
},
{
message: "receivedAt and receivedById must both be set or both be unset",
// Attach the error to both fields so it surfaces regardless of which
// one the caller looks at first.
path: ["receivedAt"],
}
);
/**
* Validates raw (untyped) purchase-order-creation input. vendorId must be a
* valid UUID string, orderDate/expectedDate must be valid date strings,
* receivedAt/receivedById are optional/nullable but must both be set or
* both be unset (co-nullability invariant — receiving a PO always records
* both "when" and "who" together). No status field is accepted — PurchaseOrder
* deliberately has no stored status (see schema.prisma comment), it's
* inferred from linked Requests.
*/
export function validateCreatePurchaseOrderInput(input: {
vendorId?: unknown;
orderDate?: unknown;
expectedDate?: unknown;
receivedAt?: unknown;
receivedById?: unknown;
}): ValidationResult {
const result = createPurchaseOrderSchema.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;
vendorId: string;
orderDate: Date;
expectedDate: Date;
receivedAt: Date | null;
receivedById: string | null;
createdAt: Date;
}): PurchaseOrderRecord {
return {
id: row.id,
vendor_id: row.vendorId,
order_date: row.orderDate.toISOString(),
expected_date: row.expectedDate.toISOString(),
received_at: row.receivedAt ? row.receivedAt.toISOString() : null,
received_by: row.receivedById,
created_at: row.createdAt.toISOString(),
};
}
export type CreatePurchaseOrderResult =
| { ok: true; value: PurchaseOrderRecord }
| { ok: false; error: string };
/**
* Creates a PurchaseOrder. Two separate FKs can fail here (vendorId ->
* Vendor, receivedById -> Employee) — Prisma raises the same error code
* (P2003) for either, so the FK constraint name in `error.meta.field_name`
* is used to tell them apart and return a targeted message, rather than a
* raw Prisma error leaking to the API layer. Mirrors
* src/lib/licenses.ts's createLicense() pattern, extended for the
* two-FK case.
*/
export async function createPurchaseOrder(
input: CreatePurchaseOrderInput
): Promise<CreatePurchaseOrderResult> {
try {
const created = await prisma.purchaseOrder.create({
data: {
vendorId: input.vendorId,
orderDate: input.orderDate,
expectedDate: input.expectedDate,
receivedAt: input.receivedAt ?? null,
receivedById: input.receivedById ?? null,
},
});
return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
if (fieldName.includes("received_by")) {
return { ok: false, error: "receivedById does not reference an existing employee" };
}
return { ok: false, error: "vendorId does not reference an existing vendor" };
}
throw error;
}
}
export async function listPurchaseOrders(): Promise<PurchaseOrderRecord[]> {
const rows = await prisma.purchaseOrder.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
+29 -5
View File
@@ -16,6 +16,7 @@ export interface RequestRecord {
type: RequestType;
reason: string;
status: RequestStatus;
purchase_order_id: string | null;
created_at: string;
}
@@ -23,6 +24,7 @@ export interface CreateRequestInput {
employee_id: string;
type: RequestType;
reason: string;
purchase_order_id?: string | null;
}
export type ValidationResult =
@@ -55,6 +57,18 @@ const createRequestSchema = z.object({
message: "type is required and must be exactly 'asset' or 'license'",
}),
}),
// Nullable/optional FK to PurchaseOrder — see schema.prisma's comment on
// Request.purchaseOrderId for the demand-vs-supply-side distinction from
// AssetItem/License's purchaseOrderId. Only a UUID *format* check here,
// whether it references a real PurchaseOrder is left to Prisma's own FK
// constraint at insert time (see createRequest()'s P2003 handling below).
// Mirrors src/lib/assets.ts's purchaseOrderId optional().nullable()
// pattern exactly.
purchase_order_id: z
.string({ invalid_type_error: "purchase_order_id must be a valid UUID string" })
.uuid("purchase_order_id must be a valid UUID string")
.optional()
.nullable(),
});
/**
@@ -66,6 +80,7 @@ export function validateCreateRequestInput(input: {
employee_id?: unknown;
type?: unknown;
reason?: unknown;
purchase_order_id?: unknown;
}): ValidationResult {
const result = createRequestSchema.safeParse(input);
if (!result.success) {
@@ -81,6 +96,7 @@ function toRecord(row: {
type: RequestType;
reason: string;
status: string;
purchaseOrderId: string | null;
createdAt: Date;
}): RequestRecord {
return {
@@ -89,6 +105,7 @@ function toRecord(row: {
type: row.type,
reason: row.reason,
status: row.status as RequestStatus,
purchase_order_id: row.purchaseOrderId,
created_at: row.createdAt.toISOString(),
};
}
@@ -98,11 +115,13 @@ export type CreateRequestResult =
| { ok: false; error: string };
/**
* Creates a Request. If employee_id doesn't reference an existing Employee,
* Prisma raises a known error (code P2003, FK violation) — caught here and
* translated into the same `{ok:false,error}` shape as input validation
* failures, rather than letting a raw Prisma error leak to the API layer.
* Mirrors src/lib/employees.ts's createEmployee() pattern for managerId.
* Creates a Request. Two separate FKs can fail here (employee_id ->
* Employee, purchase_order_id -> PurchaseOrder) — Prisma raises the same
* error code (P2003) for either, so the FK constraint name in
* `error.meta.field_name` is used to tell them apart and return a targeted
* message, rather than a raw Prisma error leaking to the API layer. Mirrors
* src/lib/licenses.ts's createLicense() pattern for disambiguating between
* two FKs on the same model.
*/
export async function createRequest(input: CreateRequestInput): Promise<CreateRequestResult> {
try {
@@ -111,11 +130,16 @@ export async function createRequest(input: CreateRequestInput): Promise<CreateRe
employeeId: input.employee_id,
type: input.type,
reason: input.reason,
purchaseOrderId: input.purchase_order_id ?? null,
},
});
return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
if (fieldName.includes("purchase_order")) {
return { ok: false, error: "purchase_order_id does not reference an existing purchase order" };
}
return { ok: false, error: "employee_id does not reference an existing employee" };
}
throw error;
+20
View File
@@ -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;
}
+123
View File
@@ -0,0 +1,123 @@
import { z } from "zod";
import { prisma } from "./prisma";
export interface VendorRecord {
id: string;
name: string;
contact_email: string;
contact_phone: string;
address: string;
tax_code: string | null;
notes: string | null;
created_at: string;
}
export interface CreateVendorInput {
name: string;
contactEmail: string;
contactPhone: string;
address: string;
taxCode?: string | null;
notes?: string | null;
}
export type ValidationResult =
| { ok: true; value: CreateVendorInput }
| { ok: false; error: string };
// Mirrors src/lib/licenses.ts's nonEmptyString helper.
function nonEmptyString(message: string) {
return z.string({ required_error: message, invalid_type_error: message }).refine(
(value) => value.trim() !== "",
{ message }
);
}
const createVendorSchema = z.object({
name: nonEmptyString("name is required and must be a non-empty string"),
contactEmail: z
.string({
required_error: "contactEmail is required and must be a valid email",
invalid_type_error: "contactEmail is required and must be a valid email",
})
.email("contactEmail is required and must be a valid email"),
contactPhone: nonEmptyString("contactPhone is required and must be a non-empty string"),
address: nonEmptyString("address is required and must be a non-empty string"),
// Nullable, per docs/asset-license-management.md's "## Vendor" spec — not
// every vendor record has a known VN tax code at creation time.
taxCode: nonEmptyString("taxCode must be a non-empty string").optional().nullable(),
notes: nonEmptyString("notes must be a non-empty string").optional().nullable(),
});
/**
* Validates raw (untyped) vendor-creation input. name/contactPhone/address
* must be non-empty strings, contactEmail must be a valid email string,
* taxCode/notes are optional/nullable (per the "## Vendor" spec in
* docs/asset-license-management.md) but must be non-empty strings if
* present.
*/
export function validateCreateVendorInput(input: {
name?: unknown;
contactEmail?: unknown;
contactPhone?: unknown;
address?: unknown;
taxCode?: unknown;
notes?: unknown;
}): ValidationResult {
const result = createVendorSchema.safeParse(input);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid input";
return { ok: false, error: message };
}
return { ok: true, value: result.data };
}
function toRecord(row: {
id: string;
name: string;
contactEmail: string;
contactPhone: string;
address: string;
taxCode: string | null;
notes: string | null;
createdAt: Date;
}): VendorRecord {
return {
id: row.id,
name: row.name,
contact_email: row.contactEmail,
contact_phone: row.contactPhone,
address: row.address,
tax_code: row.taxCode,
notes: row.notes,
created_at: row.createdAt.toISOString(),
};
}
/**
* Creates a Vendor. No unique constraint exists on any field, no FKs
* either (see docs/asset-license-management.md's "## Vendor" spec — this
* is a standalone entity, same starting shape as Employee/License before
* they grew relations) — so unlike createAssetItem()/createEmployee()
* there's no known Prisma error code to catch/translate here.
*/
export async function createVendor(input: CreateVendorInput): Promise<VendorRecord> {
const created = await prisma.vendor.create({
data: {
name: input.name,
contactEmail: input.contactEmail,
contactPhone: input.contactPhone,
address: input.address,
taxCode: input.taxCode ?? null,
notes: input.notes ?? null,
},
});
return toRecord(created);
}
export async function listVendors(): Promise<VendorRecord[]> {
const rows = await prisma.vendor.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
+306
View File
@@ -0,0 +1,306 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
import {
createAssetCategory,
listAssetCategories,
validateCreateAssetCategoryInput,
} from "@/lib/asset-categories";
import { prisma } from "@/lib/prisma";
// Note: this file has two layers of coverage, mirroring tests/vendors.test.ts:
// - lib-level tests below exercise src/lib/asset-categories.ts directly
// (validation, valueGroup enum, list ordering).
// - route-level tests (further down) exercise
// src/app/api/asset-categories/route.ts over HTTP, including the auth()
// gate — mirrors tests/vendors.test.ts's @/auth mocking pattern.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/asset-categories/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/asset-categories", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(async () => {
// Each test starts from an empty table so listing/order assertions are
// deterministic.
await prisma.assetCategory.deleteMany();
// Default to a logged-in session — the lib-level tests below don't go
// through the route at all (so this is a no-op for them), and the
// route-level tests exercise business logic, not auth gating (that's
// covered by the "auth gating" describe block further down).
mockedAuth.mockResolvedValue(testSession);
});
describe("validateCreateAssetCategoryInput", () => {
it("accepts a valid high_value category", () => {
const result = validateCreateAssetCategoryInput({
name: "Laptop",
valueGroup: "high_value",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toMatchObject({ name: "Laptop", valueGroup: "high_value" });
}
});
it("accepts a valid low_value category", () => {
const result = validateCreateAssetCategoryInput({
name: "Cáp HDMI",
valueGroup: "low_value",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toMatchObject({ name: "Cáp HDMI", valueGroup: "low_value" });
}
});
it("rejects an empty name", () => {
const result = validateCreateAssetCategoryInput({
name: "",
valueGroup: "high_value",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/name/i);
}
});
it("rejects a missing name", () => {
const result = validateCreateAssetCategoryInput({
valueGroup: "high_value",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/name/i);
}
});
it("rejects an invalid valueGroup", () => {
const result = validateCreateAssetCategoryInput({
name: "Laptop",
valueGroup: "medium_value",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/valueGroup/i);
}
});
it("rejects a missing valueGroup", () => {
const result = validateCreateAssetCategoryInput({
name: "Laptop",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/valueGroup/i);
}
});
});
describe("createAssetCategory", () => {
it("creates a high_value category", async () => {
const result = validateCreateAssetCategoryInput({
name: "Laptop",
valueGroup: "high_value",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetCategory(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value).toMatchObject({ name: "Laptop", value_group: "high_value" });
expect(typeof created.value.id).toBe("string");
expect(created.value.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(created.value.created_at))).toBe(false);
});
it("creates a low_value category", async () => {
const result = validateCreateAssetCategoryInput({
name: "Cáp HDMI",
valueGroup: "low_value",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetCategory(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value).toMatchObject({ name: "Cáp HDMI", value_group: "low_value" });
});
});
describe("listAssetCategories", () => {
it("lists created categories, newest first", async () => {
const first = validateCreateAssetCategoryInput({ name: "Cáp HDMI", valueGroup: "low_value" });
expect(first.ok).toBe(true);
if (first.ok) await createAssetCategory(first.value);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
const second = validateCreateAssetCategoryInput({ name: "Laptop", valueGroup: "high_value" });
expect(second.ok).toBe(true);
if (second.ok) await createAssetCategory(second.value);
const records = await listAssetCategories();
expect(records).toHaveLength(2);
// newest first
expect(records[0]).toMatchObject({ name: "Laptop" });
expect(records[1]).toMatchObject({ name: "Cáp HDMI" });
});
it("returns an empty list when there are no categories", async () => {
const records = await listAssetCategories();
expect(records).toEqual([]);
});
});
describe("POST /api/asset-categories", () => {
it("creates a high_value category on valid input", async () => {
const res = await POST(postRequest({ name: "Laptop", valueGroup: "high_value" }));
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({ name: "Laptop", value_group: "high_value" });
expect(typeof body.id).toBe("string");
expect(body.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
});
it("creates a low_value category on valid input", async () => {
const res = await POST(postRequest({ name: "Cáp HDMI", valueGroup: "low_value" }));
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({ name: "Cáp HDMI", value_group: "low_value" });
});
it("rejects an empty name", async () => {
const res = await POST(postRequest({ name: "", valueGroup: "high_value" }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/name/i);
});
it("rejects an invalid valueGroup", async () => {
const res = await POST(postRequest({ name: "Laptop", valueGroup: "medium_value" }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/valueGroup/i);
});
it("rejects a missing valueGroup", async () => {
const res = await POST(postRequest({ name: "Laptop" }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/valueGroup/i);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/asset-categories", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body", async () => {
// Note: arrays are `typeof "object"` in JS, so they pass the route's
// object check (same behavior as /api/vendors) — use a JSON string
// primitive here to actually exercise the "not an object" branch.
const res = await POST(postRequest("not an object"));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
});
describe("GET /api/asset-categories", () => {
it("lists created categories, newest first", async () => {
await POST(postRequest({ name: "Cáp HDMI", valueGroup: "low_value" }));
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(postRequest({ name: "Laptop", valueGroup: "high_value" }));
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body).toHaveLength(2);
// newest first
expect(body[0]).toMatchObject({ name: "Laptop" });
expect(body[1]).toMatchObject({ name: "Cáp HDMI" });
});
it("returns an empty list when there are no categories", async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
});
describe("auth gating on /api/asset-categories", () => {
// Mirrors tests/vendors.test.ts's "auth gating" block — only checks
// session presence, per the flat permission model (no role/permission
// logic).
it("POST /api/asset-categories returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await POST(postRequest({ name: "Laptop", valueGroup: "high_value" }));
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
// And it must not have created anything.
const remaining = await prisma.assetCategory.findMany();
expect(remaining).toHaveLength(0);
});
it("GET /api/asset-categories 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" });
});
});
+660
View File
@@ -0,0 +1,660 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
import {
createAssetCategoryAttributeDef,
listAssetCategoryAttributeDefs,
validateCreateAssetCategoryAttributeDefInput,
} from "@/lib/asset-category-attribute-defs";
import { prisma } from "@/lib/prisma";
// Note: this file has two layers of coverage, mirroring
// tests/purchase-orders.test.ts:
// - lib-level tests below exercise src/lib/asset-category-attribute-defs.ts
// directly (validation, FK/unique error handling, list ordering).
// - route-level tests (further down) exercise
// src/app/api/asset-category-attribute-defs/route.ts over HTTP, including
// the auth() gate.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/asset-category-attribute-defs/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/asset-category-attribute-defs", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
// AssetCategoryAttributeDef.categoryId is a real FK to AssetCategory (see
// schema.prisma / src/lib/asset-category-attribute-defs.ts) — tests that
// need valid ids create real rows first and use their ids. Mirrors
// tests/purchase-orders.test.ts's createTestVendor()/createTestEmployee()
// helpers.
function createTestAssetCategory(overrides: { name?: string } = {}) {
return prisma.assetCategory.create({
data: {
name: overrides.name ?? "Laptop",
valueGroup: "high_value",
},
});
}
beforeEach(async () => {
// Each test starts from empty tables so listing/order/uniqueness
// assertions are deterministic. asset_category_attribute_defs must be
// cleared before asset_categories since it has an FK (ON DELETE RESTRICT)
// to it.
await prisma.assetCategoryAttributeDef.deleteMany();
await prisma.assetCategory.deleteMany();
// Default to a logged-in session — the lib-level tests below don't go
// through the route at all (so this is a no-op for them), and the
// route-level tests exercise business logic, not auth gating (that's
// covered by the "auth gating" describe block further down).
mockedAuth.mockResolvedValue(testSession);
});
describe("validateCreateAssetCategoryAttributeDefInput", () => {
it("accepts valid input", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toMatchObject({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
}
});
it("accepts each of the four fixed dataType values", () => {
for (const dataType of ["string", "number", "boolean", "date"] as const) {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: `key_${dataType}`,
label: "Label",
dataType,
required: false,
sortOrder: 0,
});
expect(result.ok).toBe(true);
}
});
it("rejects a missing categoryId", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/categoryId/i);
}
});
it("rejects a categoryId that is not a valid UUID", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "not-a-uuid",
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/categoryId/i);
}
});
it("rejects an empty key", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/key/i);
}
});
it("rejects an empty label", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "ram_gb",
label: "",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/label/i);
}
});
it("rejects an invalid dataType", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "ram_gb",
label: "RAM (GB)",
dataType: "float",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/dataType/i);
}
});
it("rejects a missing dataType", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "ram_gb",
label: "RAM (GB)",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/dataType/i);
}
});
it("rejects a non-boolean required", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: "yes",
sortOrder: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/required/i);
}
});
it("rejects a non-integer sortOrder", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1.5,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/sortOrder/i);
}
});
it("rejects a missing sortOrder", () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "11111111-1111-1111-1111-111111111111",
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/sortOrder/i);
}
});
});
describe("createAssetCategoryAttributeDef", () => {
it("creates an attribute def for a real category", async () => {
const category = await createTestAssetCategory();
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetCategoryAttributeDef(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value).toMatchObject({
category_id: category.id,
key: "ram_gb",
label: "RAM (GB)",
data_type: "number",
required: true,
sort_order: 1,
});
expect(typeof created.value.id).toBe("string");
expect(created.value.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(created.value.created_at))).toBe(false);
});
it("returns a clean error when categoryId doesn't reference an existing category", async () => {
const result = validateCreateAssetCategoryAttributeDefInput({
categoryId: "00000000-0000-0000-0000-000000000000",
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetCategoryAttributeDef(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/categoryId/i);
expect(created.error).toMatch(/does not reference an existing asset category/i);
const remaining = await prisma.assetCategoryAttributeDef.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a duplicate key within the same category with a clean 400-style error", async () => {
const category = await createTestAssetCategory();
const first = validateCreateAssetCategoryAttributeDefInput({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(first.ok).toBe(true);
if (first.ok) {
const firstCreated = await createAssetCategoryAttributeDef(first.value);
expect(firstCreated.ok).toBe(true);
}
const second = validateCreateAssetCategoryAttributeDefInput({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB) duplicate",
dataType: "number",
required: false,
sortOrder: 2,
});
expect(second.ok).toBe(true);
if (!second.ok) return;
const created = await createAssetCategoryAttributeDef(second.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/key/i);
const remaining = await prisma.assetCategoryAttributeDef.findMany();
expect(remaining).toHaveLength(1);
});
it("allows the same key across two different categories", async () => {
const laptop = await createTestAssetCategory({ name: "Laptop" });
const monitor = await createTestAssetCategory({ name: "Màn hình" });
const first = validateCreateAssetCategoryAttributeDefInput({
categoryId: laptop.id,
key: "weight_kg",
label: "Khối lượng (kg)",
dataType: "number",
required: false,
sortOrder: 1,
});
expect(first.ok).toBe(true);
if (first.ok) {
const firstCreated = await createAssetCategoryAttributeDef(first.value);
expect(firstCreated.ok).toBe(true);
}
const second = validateCreateAssetCategoryAttributeDefInput({
categoryId: monitor.id,
key: "weight_kg",
label: "Khối lượng (kg)",
dataType: "number",
required: false,
sortOrder: 1,
});
expect(second.ok).toBe(true);
if (!second.ok) return;
const created = await createAssetCategoryAttributeDef(second.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.category_id).toBe(monitor.id);
expect(created.value.key).toBe("weight_kg");
const remaining = await prisma.assetCategoryAttributeDef.findMany();
expect(remaining).toHaveLength(2);
});
});
describe("listAssetCategoryAttributeDefs", () => {
it("lists created attribute defs, newest first", async () => {
const category = await createTestAssetCategory();
const first = validateCreateAssetCategoryAttributeDefInput({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
});
expect(first.ok).toBe(true);
if (first.ok) await createAssetCategoryAttributeDef(first.value);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
const second = validateCreateAssetCategoryAttributeDefInput({
categoryId: category.id,
key: "cpu",
label: "CPU",
dataType: "string",
required: false,
sortOrder: 2,
});
expect(second.ok).toBe(true);
if (second.ok) await createAssetCategoryAttributeDef(second.value);
const records = await listAssetCategoryAttributeDefs();
expect(records).toHaveLength(2);
// newest first
expect(records[0]).toMatchObject({ key: "cpu" });
expect(records[1]).toMatchObject({ key: "ram_gb" });
});
it("returns an empty list when there are no attribute defs", async () => {
const records = await listAssetCategoryAttributeDefs();
expect(records).toEqual([]);
});
});
describe("POST /api/asset-category-attribute-defs", () => {
it("creates an attribute def on valid input", async () => {
const category = await createTestAssetCategory();
const res = await POST(
postRequest({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({
category_id: category.id,
key: "ram_gb",
label: "RAM (GB)",
data_type: "number",
required: true,
sort_order: 1,
});
expect(typeof body.id).toBe("string");
expect(body.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
});
it("rejects an invalid dataType", async () => {
const category = await createTestAssetCategory();
const res = await POST(
postRequest({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB)",
dataType: "float",
required: true,
sortOrder: 1,
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/dataType/i);
});
it("rejects a categoryId that is a well-formed UUID but doesn't reference an existing category", async () => {
const res = await POST(
postRequest({
categoryId: "00000000-0000-0000-0000-000000000000",
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/categoryId/i);
expect(body.error).toMatch(/does not reference an existing asset category/i);
const remaining = await prisma.assetCategoryAttributeDef.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a duplicate (categoryId, key) with a clean 400, not a raw 500", async () => {
const category = await createTestAssetCategory();
const first = await POST(
postRequest({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
})
);
expect(first.status).toBe(201);
const second = await POST(
postRequest({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB) duplicate",
dataType: "number",
required: false,
sortOrder: 2,
})
);
expect(second.status).toBe(400);
const body = await second.json();
expect(body.error).toMatch(/key/i);
const remaining = await prisma.assetCategoryAttributeDef.findMany();
expect(remaining).toHaveLength(1);
});
it("allows the same key across two different categories via the route", async () => {
const laptop = await createTestAssetCategory({ name: "Laptop" });
const monitor = await createTestAssetCategory({ name: "Màn hình" });
const first = await POST(
postRequest({
categoryId: laptop.id,
key: "weight_kg",
label: "Khối lượng (kg)",
dataType: "number",
required: false,
sortOrder: 1,
})
);
expect(first.status).toBe(201);
const second = await POST(
postRequest({
categoryId: monitor.id,
key: "weight_kg",
label: "Khối lượng (kg)",
dataType: "number",
required: false,
sortOrder: 1,
})
);
expect(second.status).toBe(201);
const remaining = await prisma.assetCategoryAttributeDef.findMany();
expect(remaining).toHaveLength(2);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/asset-category-attribute-defs", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body", async () => {
// Note: arrays are `typeof "object"` in JS, so they pass the route's
// object check (same behavior as /api/asset-categories) — use a JSON
// string primitive here to actually exercise the "not an object" branch.
const res = await POST(postRequest("not an object"));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
});
describe("GET /api/asset-category-attribute-defs", () => {
it("lists created attribute defs, newest first", async () => {
const category = await createTestAssetCategory();
await POST(
postRequest({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
})
);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(
postRequest({
categoryId: category.id,
key: "cpu",
label: "CPU",
dataType: "string",
required: false,
sortOrder: 2,
})
);
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body).toHaveLength(2);
// newest first
expect(body[0]).toMatchObject({ key: "cpu" });
expect(body[1]).toMatchObject({ key: "ram_gb" });
});
it("returns an empty list when there are no attribute defs", async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
});
describe("auth gating on /api/asset-category-attribute-defs", () => {
// Mirrors tests/asset-categories.test.ts's "auth gating" block — only
// checks session presence, per the flat permission model (no
// role/permission logic).
it("POST /api/asset-category-attribute-defs returns 401 without a session", async () => {
const category = await createTestAssetCategory();
mockedAuth.mockResolvedValue(null);
const res = await POST(
postRequest({
categoryId: category.id,
key: "ram_gb",
label: "RAM (GB)",
dataType: "number",
required: true,
sortOrder: 1,
})
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
// And it must not have created anything.
const remaining = await prisma.assetCategoryAttributeDef.findMany();
expect(remaining).toHaveLength(0);
});
it("GET /api/asset-category-attribute-defs 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" });
});
});
+731
View File
@@ -0,0 +1,731 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
import {
createAssetModel,
listAssetModels,
validateCreateAssetModelInput,
} from "@/lib/asset-models";
import { prisma } from "@/lib/prisma";
// Note: this file has two layers of coverage, mirroring
// tests/asset-category-attribute-defs.test.ts:
// - lib-level tests below exercise src/lib/asset-models.ts directly
// (validation, FK/business-rule error handling, list ordering).
// - route-level tests (further down) exercise
// src/app/api/asset-models/route.ts over HTTP, including the auth() gate,
// malformed JSON, and non-object bodies.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/asset-models/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/asset-models", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
// AssetModel.categoryId is a real FK to AssetCategory, and the referenced
// category must be valueGroup=high_value (see docs/
// asset-license-management.md's "Cập nhật 2026-08-02 (2)" section). Tests
// that need valid ids create real rows first and use their ids. Mirrors
// tests/asset-category-attribute-defs.test.ts's createTestAssetCategory()
// helper.
function createTestAssetCategory(
overrides: { name?: string; valueGroup?: "high_value" | "low_value" } = {}
) {
return prisma.assetCategory.create({
data: {
name: overrides.name ?? "Laptop",
valueGroup: overrides.valueGroup ?? "high_value",
},
});
}
// Helper for the dynamic-specs-validation tests below: creates an
// AssetCategoryAttributeDef row directly via Prisma (no need to go through
// src/lib/asset-category-attribute-defs.ts's validation for test setup).
// Mirrors tests/asset-category-attribute-defs.test.ts's
// createTestAssetCategory() helper pattern.
function createTestAttributeDef(overrides: {
categoryId: string;
key: string;
label?: string;
dataType: "string" | "number" | "boolean" | "date";
required: boolean;
sortOrder?: number;
}) {
return prisma.assetCategoryAttributeDef.create({
data: {
categoryId: overrides.categoryId,
key: overrides.key,
label: overrides.label ?? overrides.key,
dataType: overrides.dataType,
required: overrides.required,
sortOrder: overrides.sortOrder ?? 0,
},
});
}
beforeEach(async () => {
// Each test starts from empty tables so listing/order assertions are
// deterministic. asset_models must be cleared before asset_categories
// since it has an FK (ON DELETE RESTRICT) to it. asset_category_attribute_defs
// also has an FK (ON DELETE RESTRICT) to asset_categories, so it must be
// cleared before asset_categories too — mirrors tests/
// asset-category-attribute-defs.test.ts's cleanup order.
await prisma.assetModel.deleteMany();
await prisma.assetCategoryAttributeDef.deleteMany();
await prisma.assetCategory.deleteMany();
// Default to a logged-in session — the lib-level tests below don't go
// through the route at all (so this is a no-op for them), and the
// route-level tests exercise business logic, not auth gating (that's
// covered by the "auth gating" describe block further down).
mockedAuth.mockResolvedValue(testSession);
});
describe("validateCreateAssetModelInput", () => {
it("accepts valid input with a non-trivial specs object", () => {
const result = validateCreateAssetModelInput({
categoryId: "11111111-1111-1111-1111-111111111111",
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16, cpu: "i7-1165G7" },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toMatchObject({
categoryId: "11111111-1111-1111-1111-111111111111",
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16, cpu: "i7-1165G7" },
});
}
});
it("defaults specs to {} when omitted", () => {
const result = validateCreateAssetModelInput({
categoryId: "11111111-1111-1111-1111-111111111111",
manufacturer: "Dell",
name: "Latitude 5420",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.specs).toEqual({});
}
});
it("rejects a missing categoryId", () => {
const result = validateCreateAssetModelInput({
manufacturer: "Dell",
name: "Latitude 5420",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/categoryId/i);
}
});
it("rejects a categoryId that is not a valid UUID", () => {
const result = validateCreateAssetModelInput({
categoryId: "not-a-uuid",
manufacturer: "Dell",
name: "Latitude 5420",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/categoryId/i);
}
});
it("rejects an empty manufacturer", () => {
const result = validateCreateAssetModelInput({
categoryId: "11111111-1111-1111-1111-111111111111",
manufacturer: "",
name: "Latitude 5420",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/manufacturer/i);
}
});
it("rejects an empty name", () => {
const result = validateCreateAssetModelInput({
categoryId: "11111111-1111-1111-1111-111111111111",
manufacturer: "Dell",
name: "",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/name/i);
}
});
it("rejects a specs value that is not a plain object", () => {
const result = validateCreateAssetModelInput({
categoryId: "11111111-1111-1111-1111-111111111111",
manufacturer: "Dell",
name: "Latitude 5420",
specs: "not an object",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/specs/i);
}
});
it("rejects a specs value that is an array", () => {
const result = validateCreateAssetModelInput({
categoryId: "11111111-1111-1111-1111-111111111111",
manufacturer: "Dell",
name: "Latitude 5420",
specs: ["not", "an", "object"],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/specs/i);
}
});
});
describe("createAssetModel", () => {
it("creates a model for a real high_value category, with a non-trivial specs object", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await createTestAttributeDef({
categoryId: category.id,
key: "ram_gb",
dataType: "number",
required: true,
});
await createTestAttributeDef({
categoryId: category.id,
key: "cpu",
dataType: "string",
required: true,
});
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16, cpu: "i7-1165G7" },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value).toMatchObject({
category_id: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16, cpu: "i7-1165G7" },
});
expect(typeof created.value.id).toBe("string");
expect(created.value.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(created.value.created_at))).toBe(false);
});
it("defaults specs to {} in the created record when omitted", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Apple",
name: "MacBook Pro 14",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.specs).toEqual({});
});
it("returns a clean error when categoryId doesn't reference an existing category", async () => {
const result = validateCreateAssetModelInput({
categoryId: "00000000-0000-0000-0000-000000000000",
manufacturer: "Dell",
name: "Latitude 5420",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/categoryId/i);
expect(created.error).toMatch(/does not reference an existing asset category/i);
const remaining = await prisma.assetModel.findMany();
expect(remaining).toHaveLength(0);
});
it("returns a distinct clean error when categoryId references an existing but low_value category", async () => {
const category = await createTestAssetCategory({
name: "Cáp HDMI",
valueGroup: "low_value",
});
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/high_value/i);
// Distinct message from the "doesn't exist at all" case.
expect(created.error).not.toMatch(/does not reference an existing asset category/i);
const remaining = await prisma.assetModel.findMany();
expect(remaining).toHaveLength(0);
});
});
// Dynamic specs validation, per docs/asset-license-management.md's "Cập
// nhật 2026-08-02 (3)" section: createAssetModel() builds a Zod schema at
// runtime from the category's AssetCategoryAttributeDef rows and validates
// `specs` against it — unknown keys rejected, required keys enforced, and
// each key's value type-checked per its dataType.
describe("createAssetModel: dynamic specs validation", () => {
it("accepts specs that supply all required keys with correct types", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await createTestAttributeDef({
categoryId: category.id,
key: "ram_gb",
dataType: "number",
required: true,
});
await createTestAttributeDef({
categoryId: category.id,
key: "cpu",
dataType: "string",
required: true,
});
await createTestAttributeDef({
categoryId: category.id,
key: "has_touchscreen",
dataType: "boolean",
required: true,
});
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16, cpu: "i7-1165G7", has_touchscreen: false },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.specs).toEqual({ ram_gb: 16, cpu: "i7-1165G7", has_touchscreen: false });
});
it("rejects specs missing a required key with a clean 400-style error", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await createTestAttributeDef({
categoryId: category.id,
key: "ram_gb",
dataType: "number",
required: true,
});
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: {},
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/ram_gb/i);
const remaining = await prisma.assetModel.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a specs value of the wrong type for its dataType (string where number is expected)", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await createTestAttributeDef({
categoryId: category.id,
key: "ram_gb",
dataType: "number",
required: true,
});
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: "16" },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/ram_gb/i);
const remaining = await prisma.assetModel.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a specs key not defined as an AssetCategoryAttributeDef for that category", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await createTestAttributeDef({
categoryId: category.id,
key: "ram_gb",
dataType: "number",
required: true,
});
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16, unknown_key: "surprise" },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/specs/i);
const remaining = await prisma.assetModel.findMany();
expect(remaining).toHaveLength(0);
});
it("accepts specs that omit an optional (required=false) attribute def key", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await createTestAttributeDef({
categoryId: category.id,
key: "ram_gb",
dataType: "number",
required: true,
});
await createTestAttributeDef({
categoryId: category.id,
key: "weight_kg",
dataType: "number",
required: false,
});
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16 },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.specs).toEqual({ ram_gb: 16 });
});
it("accepts a valid date string for a date-typed attribute def", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await createTestAttributeDef({
categoryId: category.id,
key: "warranty_end",
dataType: "date",
required: true,
});
const result = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { warranty_end: "2027-01-01" },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createAssetModel(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.specs).toEqual({ warranty_end: "2027-01-01" });
});
});
describe("listAssetModels", () => {
it("lists created models, newest first", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
const first = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
});
expect(first.ok).toBe(true);
if (first.ok) await createAssetModel(first.value);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
const second = validateCreateAssetModelInput({
categoryId: category.id,
manufacturer: "Apple",
name: "MacBook Pro 14",
});
expect(second.ok).toBe(true);
if (second.ok) await createAssetModel(second.value);
const records = await listAssetModels();
expect(records).toHaveLength(2);
// newest first
expect(records[0]).toMatchObject({ name: "MacBook Pro 14" });
expect(records[1]).toMatchObject({ name: "Latitude 5420" });
});
it("returns an empty list when there are no models", async () => {
const records = await listAssetModels();
expect(records).toEqual([]);
});
});
describe("POST /api/asset-models", () => {
it("creates a model on valid input", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await createTestAttributeDef({
categoryId: category.id,
key: "ram_gb",
dataType: "number",
required: true,
});
await createTestAttributeDef({
categoryId: category.id,
key: "cpu",
dataType: "string",
required: true,
});
const res = await POST(
postRequest({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16, cpu: "i7-1165G7" },
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({
category_id: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
specs: { ram_gb: 16, cpu: "i7-1165G7" },
});
expect(typeof body.id).toBe("string");
expect(body.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
});
it("defaults specs to {} when omitted from the request body", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
const res = await POST(
postRequest({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.specs).toEqual({});
});
it("rejects a missing manufacturer", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
const res = await POST(
postRequest({
categoryId: category.id,
name: "Latitude 5420",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/manufacturer/i);
});
it("rejects a categoryId that is a well-formed UUID but doesn't reference an existing category", async () => {
const res = await POST(
postRequest({
categoryId: "00000000-0000-0000-0000-000000000000",
manufacturer: "Dell",
name: "Latitude 5420",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/categoryId/i);
expect(body.error).toMatch(/does not reference an existing asset category/i);
const remaining = await prisma.assetModel.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a categoryId referencing an existing but low_value category, with a distinct 400", async () => {
const category = await createTestAssetCategory({
name: "Cáp HDMI",
valueGroup: "low_value",
});
const res = await POST(
postRequest({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/high_value/i);
const remaining = await prisma.assetModel.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/asset-models", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body", async () => {
// Note: arrays are `typeof "object"` in JS, so they pass the route's
// object check (same behavior as /api/asset-category-attribute-defs) —
// use a JSON string primitive here to actually exercise the "not an
// object" branch.
const res = await POST(postRequest("not an object"));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
});
describe("GET /api/asset-models", () => {
it("lists created models, newest first", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
await POST(
postRequest({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
})
);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(
postRequest({
categoryId: category.id,
manufacturer: "Apple",
name: "MacBook Pro 14",
})
);
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body).toHaveLength(2);
// newest first
expect(body[0]).toMatchObject({ name: "MacBook Pro 14" });
expect(body[1]).toMatchObject({ name: "Latitude 5420" });
});
it("returns an empty list when there are no models", async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
});
describe("auth gating on /api/asset-models", () => {
// Mirrors tests/asset-category-attribute-defs.test.ts's "auth gating"
// block — only checks session presence, per the flat permission model (no
// role/permission logic).
it("POST /api/asset-models returns 401 without a session", async () => {
const category = await createTestAssetCategory({ valueGroup: "high_value" });
mockedAuth.mockResolvedValue(null);
const res = await POST(
postRequest({
categoryId: category.id,
manufacturer: "Dell",
name: "Latitude 5420",
})
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
// And it must not have created anything.
const remaining = await prisma.assetModel.findMany();
expect(remaining).toHaveLength(0);
});
it("GET /api/asset-models 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" });
});
});
+218 -18
View File
@@ -24,10 +24,78 @@ function postRequest(body: unknown): Request {
});
}
// AssetItem.purchaseOrderId is a real FK to PurchaseOrder (see
// schema.prisma / src/lib/assets.ts), and PurchaseOrder.vendorId is a real
// FK to Vendor — tests that need a valid purchaseOrderId create real rows
// first and use their ids. Mirrors tests/purchase-orders.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",
},
});
}
function createTestPurchaseOrder(overrides: { vendorId?: string } = {}) {
return prisma.purchaseOrder.create({
data: {
vendorId: overrides.vendorId as string,
orderDate: new Date("2026-08-01"),
expectedDate: new Date("2026-08-10"),
},
});
}
// AssetItem.modelId is a real FK to AssetModel (see schema.prisma /
// src/lib/assets.ts), and AssetModel.categoryId is a real FK to
// AssetCategory — tests that need a valid modelId create the full chain
// first and use the model's id. Mirrors tests/asset-models.test.ts's
// createTestAssetCategory() helper. Category is always high_value here —
// AssetModel only ever references high_value categories per
// src/lib/asset-models.ts's business rule.
function createTestAssetCategory(overrides: { name?: string } = {}) {
return prisma.assetCategory.create({
data: {
name: overrides.name ?? "Laptop",
valueGroup: "high_value",
},
});
}
function createTestAssetModel(overrides: { categoryId?: string } = {}) {
return prisma.assetModel.create({
data: {
categoryId: overrides.categoryId as string,
manufacturer: "Dell",
name: "Latitude 5440",
},
});
}
async function createTestModelId(): Promise<string> {
const category = await createTestAssetCategory();
const model = await createTestAssetModel({ categoryId: category.id });
return model.id;
}
beforeEach(async () => {
// Each test starts from an empty table so listing/order assertions are
// deterministic.
// Each test starts from empty tables so listing/order assertions are
// deterministic. asset_items must be cleared before purchase_orders/
// vendors/asset_models/asset_categories since asset_items has FKs to all
// of them — deleteMany order avoids relying on cascade behavior in tests.
// purchase_orders must be cleared before vendors (ON DELETE RESTRICT to
// vendors, mirrors tests/purchase-orders.test.ts). asset_models must be
// cleared before asset_categories (ON DELETE RESTRICT, mirrors
// tests/asset-models.test.ts's cleanup order).
await prisma.assetItem.deleteMany();
await prisma.purchaseOrder.deleteMany();
await prisma.vendor.deleteMany();
await prisma.assetModel.deleteMany();
await prisma.assetCategory.deleteMany();
// Default to a logged-in session — the tests below exercise business
// logic, not auth gating (that's covered by the "auth gating" describe
// block further down).
@@ -36,8 +104,9 @@ beforeEach(async () => {
describe("POST /api/assets", () => {
it("creates an asset item in in_stock status on valid input, with no warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({ serialNumber: "SN-001", model: "Dell Latitude 5440", purchaseDate: "2026-01-15" })
postRequest({ serialNumber: "SN-001", modelId, purchaseDate: "2026-01-15" })
);
expect(res.status).toBe(201);
@@ -45,7 +114,7 @@ describe("POST /api/assets", () => {
expect(body).toMatchObject({
serial_number: "SN-001",
model: "Dell Latitude 5440",
model_id: modelId,
status: "in_stock",
warranty_end_date: null,
});
@@ -56,10 +125,11 @@ describe("POST /api/assets", () => {
});
it("creates an asset item with a warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({
serialNumber: "SN-002",
model: "Dell Latitude 5440",
modelId,
purchaseDate: "2026-01-15",
warrantyEndDate: "2029-01-15",
})
@@ -71,24 +141,26 @@ describe("POST /api/assets", () => {
});
it("rejects an empty serialNumber", async () => {
const res = await POST(postRequest({ serialNumber: "", model: "Dell", purchaseDate: "2026-01-15" }));
const modelId = await createTestModelId();
const res = await POST(postRequest({ serialNumber: "", modelId, purchaseDate: "2026-01-15" }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/serialNumber/i);
});
it("rejects an empty model", async () => {
const res = await POST(postRequest({ serialNumber: "SN-003", model: "", purchaseDate: "2026-01-15" }));
it("rejects a missing modelId", async () => {
const res = await POST(postRequest({ serialNumber: "SN-003", purchaseDate: "2026-01-15" }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/model/i);
expect(body.error).toMatch(/modelId/i);
});
it("rejects an invalid purchaseDate", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({ serialNumber: "SN-004", model: "Dell", purchaseDate: "not-a-date" })
postRequest({ serialNumber: "SN-004", modelId, purchaseDate: "not-a-date" })
);
expect(res.status).toBe(400);
@@ -97,10 +169,11 @@ describe("POST /api/assets", () => {
});
it("rejects an invalid warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({
serialNumber: "SN-005",
model: "Dell",
modelId,
purchaseDate: "2026-01-15",
warrantyEndDate: "not-a-date",
})
@@ -111,9 +184,77 @@ describe("POST /api/assets", () => {
expect(body.error).toMatch(/warrantyEndDate/i);
});
it("creates an asset item with a purchaseOrderId that references a real purchase order", async () => {
const modelId = await createTestModelId();
const vendor = await createTestVendor();
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
const res = await POST(
postRequest({
serialNumber: "SN-007",
modelId,
purchaseDate: "2026-01-15",
purchaseOrderId: purchaseOrder.id,
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.purchase_order_id).toBe(purchaseOrder.id);
});
it("creates an asset item with no purchaseOrderId (stays null)", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({ serialNumber: "SN-008", modelId, purchaseDate: "2026-01-15" })
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.purchase_order_id).toBeNull();
});
it("rejects a purchaseOrderId that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({
serialNumber: "SN-009",
modelId,
purchaseDate: "2026-01-15",
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/purchaseOrderId/i);
expect(body.error).toMatch(/does not reference an existing purchase order/i);
// And it must not have created anything.
const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a purchaseOrderId that is not a valid UUID", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({
serialNumber: "SN-012",
modelId,
purchaseDate: "2026-01-15",
purchaseOrderId: "not-a-uuid",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/purchaseOrderId/i);
});
it("rejects a duplicate serialNumber", async () => {
await POST(postRequest({ serialNumber: "SN-006", model: "Dell", purchaseDate: "2026-01-15" }));
const res = await POST(postRequest({ serialNumber: "SN-006", model: "HP", purchaseDate: "2026-02-01" }));
const modelId = await createTestModelId();
await POST(postRequest({ serialNumber: "SN-006", modelId, purchaseDate: "2026-01-15" }));
const res = await POST(postRequest({ serialNumber: "SN-006", modelId, purchaseDate: "2026-02-01" }));
expect(res.status).toBe(400);
const body = await res.json();
@@ -123,14 +264,72 @@ describe("POST /api/assets", () => {
const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(1);
});
it("rejects a modelId that is not a valid UUID", async () => {
const res = await POST(
postRequest({ serialNumber: "SN-013", modelId: "not-a-uuid", purchaseDate: "2026-01-15" })
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/modelId/i);
// And it must not have created anything.
const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a modelId that is a well-formed UUID but doesn't reference an existing asset model", async () => {
const res = await POST(
postRequest({
serialNumber: "SN-014",
modelId: "00000000-0000-0000-0000-000000000000",
purchaseDate: "2026-01-15",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/modelId/i);
expect(body.error).toMatch(/does not reference an existing asset model/i);
// And it must not have created anything.
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", () => {
it("lists created asset items, newest first", async () => {
await POST(postRequest({ serialNumber: "SN-010", model: "Dell", purchaseDate: "2026-01-15" }));
const modelId = await createTestModelId();
await POST(postRequest({ serialNumber: "SN-010", modelId, purchaseDate: "2026-01-15" }));
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(postRequest({ serialNumber: "SN-011", model: "HP", purchaseDate: "2026-02-01" }));
await POST(postRequest({ serialNumber: "SN-011", modelId, purchaseDate: "2026-02-01" }));
const res = await GET();
expect(res.status).toBe(200);
@@ -140,8 +339,8 @@ describe("GET /api/assets", () => {
expect(body).toHaveLength(2);
// newest first
expect(body[0]).toMatchObject({ serial_number: "SN-011", model: "HP" });
expect(body[1]).toMatchObject({ serial_number: "SN-010", model: "Dell" });
expect(body[0]).toMatchObject({ serial_number: "SN-011", model_id: modelId });
expect(body[1]).toMatchObject({ serial_number: "SN-010", model_id: modelId });
});
it("returns an empty list when there are no asset items", async () => {
@@ -157,10 +356,11 @@ describe("auth gating on /api/assets", () => {
// session presence, per the flat permission model (no role/permission
// logic).
it("POST /api/assets returns 401 without a session", async () => {
const modelId = await createTestModelId();
mockedAuth.mockResolvedValue(null);
const res = await POST(
postRequest({ serialNumber: "SN-020", model: "Dell", purchaseDate: "2026-01-15" })
postRequest({ serialNumber: "SN-020", modelId, purchaseDate: "2026-01-15" })
);
expect(res.status).toBe(401);
+956
View File
@@ -0,0 +1,956 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
import {
createAssignment,
listAssignments,
returnAssignment,
transferAssignment,
validateCreateAssignmentInput,
validateReturnAssignmentInput,
validateTransferAssignmentInput,
} from "@/lib/assignments";
import { prisma } from "@/lib/prisma";
// This file has two layers of coverage, mirroring tests/licenses.test.ts:
// - lib-level tests exercise src/lib/assignments.ts directly (validation,
// transactional business rules, race conditions).
// - route-level tests exercise src/app/api/assignments/route.ts and
// src/app/api/assignments/[id]/route.ts over HTTP, including the auth()
// gate — mirrors tests/licenses.test.ts's @/auth mocking pattern.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/assignments/route";
import { PATCH } from "@/app/api/assignments/[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/assignments", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function patchRequest(body: unknown): Request {
return new Request("http://localhost/api/assignments/x", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
// Assignment.employeeId is a real FK to Employee — mirrors
// tests/requests.test.ts's createTestEmployee() helper.
function createTestEmployee(overrides: { name?: string } = {}) {
return prisma.employee.create({
data: {
name: overrides.name ?? "Test Employee",
department: "IT",
},
});
}
// Assignment.assetItemId is a real FK to AssetItem, which itself requires a
// full AssetModel -> AssetCategory chain. Mirrors tests/assets.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-ASSIGN-${serialCounter}`,
modelId: overrides.modelId,
purchaseDate: new Date("2026-01-15"),
status: overrides.status ?? "in_stock",
},
});
}
// Assignment.licenseId is a real FK to License, which itself requires a
// Vendor. Mirrors tests/licenses.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",
},
});
}
function createTestLicense(overrides: {
vendorId: string;
seatsTotal?: number;
seatsUsed?: number;
}) {
return prisma.license.create({
data: {
name: "Adobe Creative Cloud",
vendorId: overrides.vendorId,
seatsTotal: overrides.seatsTotal ?? 5,
seatsUsed: overrides.seatsUsed ?? 0,
licenseType: "subscription",
},
});
}
async function createTestLicenseId(overrides: { seatsTotal?: number; seatsUsed?: number } = {}): Promise<string> {
const vendor = await createTestVendor();
const license = await createTestLicense({ vendorId: vendor.id, ...overrides });
return license.id;
}
beforeEach(async () => {
// Clear in FK-safe order: assignments first (references everything else),
// then asset_items/licenses (reference purchase_orders/vendors/models),
// then the rest.
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("validateCreateAssignmentInput", () => {
it("accepts input with only assetItemId set", () => {
const result = validateCreateAssignmentInput({
employeeId: "11111111-1111-1111-1111-111111111111",
assetItemId: "22222222-2222-2222-2222-222222222222",
});
expect(result.ok).toBe(true);
});
it("accepts input with only licenseId set", () => {
const result = validateCreateAssignmentInput({
employeeId: "11111111-1111-1111-1111-111111111111",
licenseId: "33333333-3333-3333-3333-333333333333",
});
expect(result.ok).toBe(true);
});
it("rejects input with neither assetItemId nor licenseId set", () => {
const result = validateCreateAssignmentInput({
employeeId: "11111111-1111-1111-1111-111111111111",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/exactly one/i);
}
});
it("rejects input with both assetItemId and licenseId set", () => {
const result = validateCreateAssignmentInput({
employeeId: "11111111-1111-1111-1111-111111111111",
assetItemId: "22222222-2222-2222-2222-222222222222",
licenseId: "33333333-3333-3333-3333-333333333333",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/exactly one/i);
}
});
it("rejects a missing employeeId", () => {
const result = validateCreateAssignmentInput({
assetItemId: "22222222-2222-2222-2222-222222222222",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/employeeId/i);
}
});
it("rejects a non-UUID assetItemId", () => {
const result = validateCreateAssignmentInput({
employeeId: "11111111-1111-1111-1111-111111111111",
assetItemId: "not-a-uuid",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/assetItemId/i);
}
});
});
describe("validateReturnAssignmentInput", () => {
it("accepts an empty body (returnedAt defaults to now later)", () => {
const result = validateReturnAssignmentInput({});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.returnedAt).toBeUndefined();
}
});
it("accepts an explicit returnedAt", () => {
const result = validateReturnAssignmentInput({ returnedAt: "2026-08-05" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.returnedAt).toBeInstanceOf(Date);
}
});
it("rejects an invalid returnedAt", () => {
const result = validateReturnAssignmentInput({ returnedAt: "not-a-date" });
expect(result.ok).toBe(false);
});
});
describe("createAssignment", () => {
it("assigns an in_stock AssetItem, flips its status to assigned", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.asset_item_id).toBe(assetItem.id);
expect(created.value.license_id).toBeNull();
expect(created.value.returned_at).toBeNull();
const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(updatedItem.status).toBe("assigned");
});
it.each(["repair", "retired", "assigned"] as const)(
"rejects assigning an AssetItem whose status is %s",
async (status) => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId, status });
const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/not in_stock/i);
const remaining = await prisma.assignment.findMany();
expect(remaining).toHaveLength(0);
}
);
it("rejects assigning an AssetItem that already has an active assignment (belt-and-suspenders check)", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const first = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
expect(first.ok).toBe(true);
// Manually force status back to in_stock without closing the active
// Assignment row, to exercise the explicit "active assignment already
// exists" invariant independently of the status check (data-drift
// scenario per src/lib/assignments.ts's doc comment).
await prisma.assetItem.update({ where: { id: assetItem.id }, data: { status: "in_stock" } });
const second = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
expect(second.ok).toBe(false);
if (second.ok) return;
expect(second.error).toMatch(/already has an active assignment/i);
});
it("rejects assigning an AssetItem for a second employee while the first assignment is still active", 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 first = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
expect(first.ok).toBe(true);
const second = await createAssignment({ employeeId: employee2.id, assetItemId: assetItem.id });
expect(second.ok).toBe(false);
if (second.ok) return;
expect(second.error).toMatch(/not in_stock/i);
const activeAssignments = await prisma.assignment.findMany({ where: { assetItemId: assetItem.id, returnedAt: null } });
expect(activeAssignments).toHaveLength(1);
});
it("allows re-assigning an AssetItem to a new employee after it has been returned", 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 first = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
expect(first.ok).toBe(true);
if (!first.ok) return;
const returned = await returnAssignment(first.value.id, {});
expect(returned.ok).toBe(true);
const second = await createAssignment({ employeeId: employee2.id, assetItemId: assetItem.id });
expect(second.ok).toBe(true);
if (!second.ok) return;
expect(second.value.employee_id).toBe(employee2.id);
const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(updatedItem.status).toBe("assigned");
});
it("returns a clean error when assetItemId doesn't reference an existing asset item", async () => {
const employee = await createTestEmployee();
const created = await createAssignment({
employeeId: employee.id,
assetItemId: "00000000-0000-0000-0000-000000000000",
});
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/assetItemId/i);
expect(created.error).toMatch(/does not reference an existing asset item/i);
});
it("returns a clean error when employeeId doesn't reference an existing employee", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({
employeeId: "00000000-0000-0000-0000-000000000000",
assetItemId: assetItem.id,
});
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/employeeId/i);
expect(created.error).toMatch(/does not reference an existing employee/i);
// And the AssetItem's status must NOT have been mutated — the
// transaction must have rolled back entirely.
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("in_stock");
});
it("assigns a license seat, increments seatsUsed", async () => {
const employee = await createTestEmployee();
const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 2 });
const created = await createAssignment({ employeeId: employee.id, licenseId });
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.license_id).toBe(licenseId);
const license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(3);
});
it("rejects assigning a license with no seats remaining (seatsUsed >= seatsTotal)", async () => {
const employee = await createTestEmployee();
const licenseId = await createTestLicenseId({ seatsTotal: 3, seatsUsed: 3 });
const created = await createAssignment({ employeeId: employee.id, licenseId });
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/no seats remaining/i);
const license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(3);
const remaining = await prisma.assignment.findMany();
expect(remaining).toHaveLength(0);
});
it("returns a clean error when licenseId doesn't reference an existing license", async () => {
const employee = await createTestEmployee();
const created = await createAssignment({
employeeId: employee.id,
licenseId: "00000000-0000-0000-0000-000000000000",
});
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/licenseId/i);
expect(created.error).toMatch(/does not reference an existing license/i);
});
it("closes the race: two concurrent createAssignment calls against a license with exactly 1 remaining seat — at least one must fail, seatsUsed never exceeds seatsTotal", async () => {
const employee1 = await createTestEmployee({ name: "Racer 1" });
const employee2 = await createTestEmployee({ name: "Racer 2" });
// seatsTotal=3, seatsUsed=2 -> exactly 1 seat remaining.
const licenseId = await createTestLicenseId({ seatsTotal: 3, seatsUsed: 2 });
const [resultA, resultB] = await Promise.all([
createAssignment({ employeeId: employee1.id, licenseId }),
createAssignment({ employeeId: employee2.id, licenseId }),
]);
const outcomes = [resultA, resultB];
const succeeded = outcomes.filter((r) => r.ok);
const failed = outcomes.filter((r) => !r.ok);
expect(succeeded).toHaveLength(1);
expect(failed).toHaveLength(1);
if (!failed[0].ok) {
expect(failed[0].error).toMatch(/no seats remaining/i);
}
const license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(3);
expect(license.seatsUsed).toBeLessThanOrEqual(license.seatsTotal);
const assignments = await prisma.assignment.findMany({ where: { licenseId } });
expect(assignments).toHaveLength(1);
});
it("closes the race: two concurrent createAssignment calls against the same in_stock AssetItem — at least one must fail", async () => {
const employee1 = await createTestEmployee({ name: "Racer 1" });
const employee2 = await createTestEmployee({ name: "Racer 2" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const [resultA, resultB] = await Promise.all([
createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id }),
createAssignment({ employeeId: employee2.id, assetItemId: assetItem.id }),
]);
const outcomes = [resultA, resultB];
const succeeded = outcomes.filter((r) => r.ok);
const failed = outcomes.filter((r) => !r.ok);
expect(succeeded).toHaveLength(1);
expect(failed).toHaveLength(1);
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("assigned");
const activeAssignments = await prisma.assignment.findMany({
where: { assetItemId: assetItem.id, returnedAt: null },
});
expect(activeAssignments).toHaveLength(1);
});
});
describe("returnAssignment", () => {
it("returns an AssetItem assignment: sets returnedAt, flips status back to in_stock", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee.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);
if (!returned.ok) return;
expect(returned.value.returned_at).not.toBeNull();
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("in_stock");
});
it("accepts an explicit returnedAt", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
expect(created.ok).toBe(true);
if (!created.ok) return;
const explicitDate = new Date("2026-08-01T00:00:00.000Z");
const returned = await returnAssignment(created.value.id, { returnedAt: explicitDate });
expect(returned.ok).toBe(true);
if (!returned.ok) return;
expect(returned.value.returned_at).toBe(explicitDate.toISOString());
});
it("returns a license assignment: sets returnedAt, decrements seatsUsed", async () => {
const employee = await createTestEmployee();
const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 2 });
const created = await createAssignment({ employeeId: employee.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 returned = await returnAssignment(created.value.id, {});
expect(returned.ok).toBe(true);
license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(2);
});
it("rejects returning an already-returned assignment (double return)", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
expect(created.ok).toBe(true);
if (!created.ok) return;
const firstReturn = await returnAssignment(created.value.id, {});
expect(firstReturn.ok).toBe(true);
const secondReturn = await returnAssignment(created.value.id, {});
expect(secondReturn.ok).toBe(false);
if (secondReturn.ok) return;
expect(secondReturn.status).toBe(400);
expect(secondReturn.error).toMatch(/already been returned/i);
});
it("returns 404 when returning a nonexistent assignment", async () => {
const result = await returnAssignment("00000000-0000-0000-0000-000000000000", {});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(404);
expect(result.error).toMatch(/not found/i);
});
it("closes the race: two concurrent returnAssignment calls against the same assignment — only one succeeds", async () => {
const employee = await createTestEmployee();
const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 0 });
const created = await createAssignment({ employeeId: employee.id, licenseId });
expect(created.ok).toBe(true);
if (!created.ok) return;
const [resultA, resultB] = await Promise.all([
returnAssignment(created.value.id, {}),
returnAssignment(created.value.id, {}),
]);
const outcomes = [resultA, resultB];
const succeeded = outcomes.filter((r) => r.ok);
const failed = outcomes.filter((r) => !r.ok);
expect(succeeded).toHaveLength(1);
expect(failed).toHaveLength(1);
const license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(0);
});
});
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();
const modelId = await createTestModelId();
const item1 = await createTestAssetItem({ modelId });
const item2 = await createTestAssetItem({ modelId });
const first = await createAssignment({ employeeId: employee.id, assetItemId: item1.id });
expect(first.ok).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 5));
const second = await createAssignment({ employeeId: employee.id, assetItemId: item2.id });
expect(second.ok).toBe(true);
const records = await listAssignments();
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 assignments", async () => {
const records = await listAssignments();
expect(records).toEqual([]);
});
});
describe("POST /api/assignments", () => {
it("creates an assignment on valid input", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const res = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id }));
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({ employee_id: employee.id, asset_item_id: assetItem.id, license_id: null });
});
it("rejects both assetItemId and licenseId set", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const licenseId = await createTestLicenseId();
const res = await POST(
postRequest({ employeeId: employee.id, assetItemId: assetItem.id, licenseId })
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/exactly one/i);
});
it("rejects neither assetItemId nor licenseId set", async () => {
const employee = await createTestEmployee();
const res = await POST(postRequest({ employeeId: employee.id }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/exactly one/i);
});
it("rejects assigning an AssetItem that isn't in_stock", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId, status: "repair" });
const res = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/not in_stock/i);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/assignments", {
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 () => {
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/assignments", () => {
it("lists created assignments, newest first", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const item1 = await createTestAssetItem({ modelId });
const item2 = await createTestAssetItem({ modelId });
await POST(postRequest({ employeeId: employee.id, assetItemId: item1.id }));
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(postRequest({ employeeId: employee.id, assetItemId: item2.id }));
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 assignments", async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
});
describe("PATCH /api/assignments/[id]", () => {
it("returns an assignment on valid input", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id }));
const created = await createRes.json();
const res = await PATCH(patchRequest({}), { params: { id: created.id } });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.returned_at).not.toBeNull();
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("in_stock");
});
it("returns 404 for a nonexistent assignment id", async () => {
const res = await PATCH(patchRequest({}), {
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({}), { params: { id: "not-a-uuid" } });
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/UUID/i);
});
it("rejects double-return with 400", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id }));
const created = await createRes.json();
await PATCH(patchRequest({}), { params: { id: created.id } });
const res = await PATCH(patchRequest({}), { params: { id: created.id } });
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/already been returned/i);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/assignments/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", 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("returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await PATCH(patchRequest({}), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(401);
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", () => {
it("POST /api/assignments returns 401 without a session", async () => {
const employee = await createTestEmployee();
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
mockedAuth.mockResolvedValue(null);
const res = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id }));
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
const remaining = await prisma.assignment.findMany();
expect(remaining).toHaveLength(0);
});
it("GET /api/assignments 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" });
});
});
+16
View File
@@ -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);
});
});
+24
View File
@@ -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", () => {
+294 -39
View File
@@ -34,10 +34,45 @@ function postRequest(body: unknown): Request {
});
}
// License.vendorId is a real FK to Vendor (see schema.prisma /
// src/lib/licenses.ts) — tests that need a valid vendorId create a real
// Vendor row first and use its id, rather than an arbitrary string. Mirrors
// tests/requests.test.ts's createTestEmployee() 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",
},
});
}
// License.purchaseOrderId is a real FK to PurchaseOrder (see schema.prisma /
// src/lib/licenses.ts) — tests that need a valid purchaseOrderId create a
// real PurchaseOrder row first and use its id. Mirrors
// tests/assets.test.ts's createTestPurchaseOrder() helper.
function createTestPurchaseOrder(overrides: { vendorId?: string } = {}) {
return prisma.purchaseOrder.create({
data: {
vendorId: overrides.vendorId as string,
orderDate: new Date("2026-08-01"),
expectedDate: new Date("2026-08-10"),
},
});
}
beforeEach(async () => {
// Each test starts from an empty table so listing/order assertions are
// deterministic.
// Each test starts from empty tables so listing/order assertions are
// deterministic. licenses must be cleared before purchase_orders/vendors
// since licenses has FKs (ON DELETE RESTRICT to vendors, ON DELETE SET
// NULL to purchase_orders) to both. purchase_orders must be cleared
// before vendors (ON DELETE RESTRICT to vendors, mirrors
// tests/assets.test.ts).
await prisma.license.deleteMany();
await prisma.purchaseOrder.deleteMany();
await prisma.vendor.deleteMany();
// Default to a logged-in session — the lib-level tests below don't go
// through the route at all (so this is a no-op for them), and the
// route-level tests exercise business logic, not auth gating (that's
@@ -49,7 +84,7 @@ describe("validateCreateLicenseInput", () => {
it("accepts valid input with an expiryDate", () => {
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendor: "Adobe",
vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 10,
expiryDate: "2027-01-15",
licenseType: "subscription",
@@ -59,7 +94,7 @@ describe("validateCreateLicenseInput", () => {
if (result.ok) {
expect(result.value).toMatchObject({
name: "Adobe Creative Cloud",
vendor: "Adobe",
vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 10,
licenseType: "subscription",
});
@@ -70,7 +105,7 @@ describe("validateCreateLicenseInput", () => {
it("accepts valid input with no expiryDate (perpetual license)", () => {
const result = validateCreateLicenseInput({
name: "Windows Server 2022",
vendor: "Microsoft",
vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 5,
licenseType: "perpetual",
});
@@ -84,7 +119,7 @@ describe("validateCreateLicenseInput", () => {
it("rejects an empty name", () => {
const result = validateCreateLicenseInput({
name: "",
vendor: "Adobe",
vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 10,
licenseType: "subscription",
});
@@ -95,24 +130,37 @@ describe("validateCreateLicenseInput", () => {
}
});
it("rejects an empty vendor", () => {
it("rejects a vendorId that is not a valid UUID", () => {
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendor: "",
vendorId: "not-a-uuid",
seatsTotal: 10,
licenseType: "subscription",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/vendor/i);
expect(result.error).toMatch(/vendorId/i);
}
});
it("rejects a missing vendorId", () => {
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
seatsTotal: 10,
licenseType: "subscription",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/vendorId/i);
}
});
it("rejects a zero seatsTotal", () => {
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendor: "Adobe",
vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 0,
licenseType: "subscription",
});
@@ -126,7 +174,7 @@ describe("validateCreateLicenseInput", () => {
it("rejects a negative seatsTotal", () => {
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendor: "Adobe",
vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: -5,
licenseType: "subscription",
});
@@ -140,7 +188,7 @@ describe("validateCreateLicenseInput", () => {
it("rejects a non-integer seatsTotal", () => {
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendor: "Adobe",
vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 3.5,
licenseType: "subscription",
});
@@ -154,7 +202,7 @@ describe("validateCreateLicenseInput", () => {
it("rejects an invalid expiryDate", () => {
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendor: "Adobe",
vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 10,
expiryDate: "not-a-date",
licenseType: "subscription",
@@ -169,7 +217,7 @@ describe("validateCreateLicenseInput", () => {
it("rejects an invalid licenseType", () => {
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendor: "Adobe",
vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 10,
licenseType: "trial",
});
@@ -183,7 +231,7 @@ describe("validateCreateLicenseInput", () => {
it("rejects a missing licenseType", () => {
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendor: "Adobe",
vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 10,
});
@@ -192,13 +240,29 @@ describe("validateCreateLicenseInput", () => {
expect(result.error).toMatch(/licenseType/i);
}
});
it("rejects a purchaseOrderId that is not a valid UUID", () => {
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendorId: "11111111-1111-1111-1111-111111111111",
seatsTotal: 10,
licenseType: "subscription",
purchaseOrderId: "not-a-uuid",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/purchaseOrderId/i);
}
});
});
describe("createLicense", () => {
it("creates a license with seatsUsed always 0, regardless of client input", async () => {
const vendor = await createTestVendor();
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendor: "Adobe",
vendorId: vendor.id,
seatsTotal: 10,
// @ts-expect-error -- seatsUsed is intentionally not part of
// CreateLicenseInput; this simulates a client trying to sneak it in.
@@ -209,12 +273,15 @@ describe("createLicense", () => {
expect(result.ok).toBe(true);
if (!result.ok) return;
const record = await createLicense(result.value);
const created = await createLicense(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
const record = created.value;
expect(record.seats_used).toBe(0);
expect(record).toMatchObject({
name: "Adobe Creative Cloud",
vendor: "Adobe",
vendor_id: vendor.id,
seats_total: 10,
license_type: "subscription",
});
@@ -225,28 +292,113 @@ describe("createLicense", () => {
});
it("creates a perpetual license with no expiryDate", async () => {
const vendor = await createTestVendor();
const result = validateCreateLicenseInput({
name: "Windows Server 2022",
vendor: "Microsoft",
vendorId: vendor.id,
seatsTotal: 5,
licenseType: "perpetual",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const record = await createLicense(result.value);
const created = await createLicense(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
const record = created.value;
expect(record.expiry_date).toBeNull();
expect(record.seats_used).toBe(0);
expect(record.license_type).toBe("perpetual");
});
it("returns a clean error when vendorId doesn't reference an existing vendor", async () => {
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendorId: "00000000-0000-0000-0000-000000000000",
seatsTotal: 10,
licenseType: "subscription",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createLicense(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/vendorId/i);
expect(created.error).toMatch(/does not reference an existing vendor/i);
const remaining = await prisma.license.findMany();
expect(remaining).toHaveLength(0);
});
it("creates a license with a purchaseOrderId that references a real purchase order", async () => {
const vendor = await createTestVendor();
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendorId: vendor.id,
seatsTotal: 10,
licenseType: "subscription",
purchaseOrderId: purchaseOrder.id,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createLicense(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.purchase_order_id).toBe(purchaseOrder.id);
});
it("creates a license with no purchaseOrderId (stays null)", async () => {
const vendor = await createTestVendor();
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendorId: vendor.id,
seatsTotal: 10,
licenseType: "subscription",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createLicense(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.purchase_order_id).toBeNull();
});
it("returns a clean error when purchaseOrderId doesn't reference an existing purchase order", async () => {
const vendor = await createTestVendor();
const result = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendorId: vendor.id,
seatsTotal: 10,
licenseType: "subscription",
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createLicense(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/purchaseOrderId/i);
expect(created.error).toMatch(/does not reference an existing purchase order/i);
const remaining = await prisma.license.findMany();
expect(remaining).toHaveLength(0);
});
});
describe("listLicenses", () => {
it("lists created licenses, newest first", async () => {
const vendor1 = await createTestVendor({ name: "Microsoft VN" });
const vendor2 = await createTestVendor({ name: "Adobe VN" });
const first = validateCreateLicenseInput({
name: "Windows Server 2022",
vendor: "Microsoft",
vendorId: vendor1.id,
seatsTotal: 5,
licenseType: "perpetual",
});
@@ -258,7 +410,7 @@ describe("listLicenses", () => {
const second = validateCreateLicenseInput({
name: "Adobe Creative Cloud",
vendor: "Adobe",
vendorId: vendor2.id,
seatsTotal: 10,
licenseType: "subscription",
});
@@ -269,8 +421,8 @@ describe("listLicenses", () => {
expect(records).toHaveLength(2);
// newest first
expect(records[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor: "Adobe" });
expect(records[1]).toMatchObject({ name: "Windows Server 2022", vendor: "Microsoft" });
expect(records[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor_id: vendor2.id });
expect(records[1]).toMatchObject({ name: "Windows Server 2022", vendor_id: vendor1.id });
});
it("returns an empty list when there are no licenses", async () => {
@@ -281,10 +433,11 @@ describe("listLicenses", () => {
describe("POST /api/licenses", () => {
it("creates a license with seatsUsed always 0 on valid input", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({
name: "Adobe Creative Cloud",
vendor: "Adobe",
vendorId: vendor.id,
seatsTotal: 10,
expiryDate: "2027-01-15",
licenseType: "subscription",
@@ -296,7 +449,7 @@ describe("POST /api/licenses", () => {
expect(body).toMatchObject({
name: "Adobe Creative Cloud",
vendor: "Adobe",
vendor_id: vendor.id,
seats_total: 10,
seats_used: 0,
license_type: "subscription",
@@ -308,10 +461,11 @@ describe("POST /api/licenses", () => {
});
it("creates a perpetual license with no expiryDate", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({
name: "Windows Server 2022",
vendor: "Microsoft",
vendorId: vendor.id,
seatsTotal: 5,
licenseType: "perpetual",
})
@@ -324,8 +478,9 @@ describe("POST /api/licenses", () => {
});
it("rejects an empty name", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({ name: "", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
postRequest({ name: "", vendorId: vendor.id, seatsTotal: 10, licenseType: "subscription" })
);
expect(res.status).toBe(400);
@@ -333,19 +488,45 @@ describe("POST /api/licenses", () => {
expect(body.error).toMatch(/name/i);
});
it("rejects an empty vendor", async () => {
it("rejects a vendorId that is not a valid UUID", async () => {
const res = await POST(
postRequest({ name: "Adobe Creative Cloud", vendor: "", seatsTotal: 10, licenseType: "subscription" })
postRequest({
name: "Adobe Creative Cloud",
vendorId: "not-a-uuid",
seatsTotal: 10,
licenseType: "subscription",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/vendor/i);
expect(body.error).toMatch(/vendorId/i);
});
it("rejects a vendorId that is a well-formed UUID but doesn't reference an existing vendor", async () => {
const res = await POST(
postRequest({
name: "Adobe Creative Cloud",
vendorId: "00000000-0000-0000-0000-000000000000",
seatsTotal: 10,
licenseType: "subscription",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/vendorId/i);
expect(body.error).toMatch(/does not reference an existing vendor/i);
// And it must not have created anything.
const remaining = await prisma.license.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a zero seatsTotal", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 0, licenseType: "subscription" })
postRequest({ name: "Adobe Creative Cloud", vendorId: vendor.id, seatsTotal: 0, licenseType: "subscription" })
);
expect(res.status).toBe(400);
@@ -354,10 +535,11 @@ describe("POST /api/licenses", () => {
});
it("rejects an invalid expiryDate", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({
name: "Adobe Creative Cloud",
vendor: "Adobe",
vendorId: vendor.id,
seatsTotal: 10,
expiryDate: "not-a-date",
licenseType: "subscription",
@@ -370,8 +552,9 @@ describe("POST /api/licenses", () => {
});
it("rejects an invalid licenseType", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "trial" })
postRequest({ name: "Adobe Creative Cloud", vendorId: vendor.id, seatsTotal: 10, licenseType: "trial" })
);
expect(res.status).toBe(400);
@@ -379,6 +562,74 @@ describe("POST /api/licenses", () => {
expect(body.error).toMatch(/licenseType/i);
});
it("creates a license with a purchaseOrderId that references a real purchase order", async () => {
const vendor = await createTestVendor();
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
const res = await POST(
postRequest({
name: "Adobe Creative Cloud",
vendorId: vendor.id,
seatsTotal: 10,
licenseType: "subscription",
purchaseOrderId: purchaseOrder.id,
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.purchase_order_id).toBe(purchaseOrder.id);
});
it("creates a license with no purchaseOrderId (stays null)", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({ name: "Adobe Creative Cloud", vendorId: vendor.id, seatsTotal: 10, licenseType: "subscription" })
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.purchase_order_id).toBeNull();
});
it("rejects a purchaseOrderId that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({
name: "Adobe Creative Cloud",
vendorId: vendor.id,
seatsTotal: 10,
licenseType: "subscription",
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/purchaseOrderId/i);
expect(body.error).toMatch(/does not reference an existing purchase order/i);
// And it must not have created anything.
const remaining = await prisma.license.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a purchaseOrderId that is not a valid UUID", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({
name: "Adobe Creative Cloud",
vendorId: vendor.id,
seatsTotal: 10,
licenseType: "subscription",
purchaseOrderId: "not-a-uuid",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/purchaseOrderId/i);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/licenses", {
method: "POST",
@@ -406,13 +657,16 @@ describe("POST /api/licenses", () => {
describe("GET /api/licenses", () => {
it("lists created licenses, newest first", async () => {
const vendor1 = await createTestVendor({ name: "Microsoft VN" });
const vendor2 = await createTestVendor({ name: "Adobe VN" });
await POST(
postRequest({ name: "Windows Server 2022", vendor: "Microsoft", seatsTotal: 5, licenseType: "perpetual" })
postRequest({ name: "Windows Server 2022", vendorId: vendor1.id, seatsTotal: 5, licenseType: "perpetual" })
);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
postRequest({ name: "Adobe Creative Cloud", vendorId: vendor2.id, seatsTotal: 10, licenseType: "subscription" })
);
const res = await GET();
@@ -423,8 +677,8 @@ describe("GET /api/licenses", () => {
expect(body).toHaveLength(2);
// newest first
expect(body[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor: "Adobe" });
expect(body[1]).toMatchObject({ name: "Windows Server 2022", vendor: "Microsoft" });
expect(body[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor_id: vendor2.id });
expect(body[1]).toMatchObject({ name: "Windows Server 2022", vendor_id: vendor1.id });
});
it("returns an empty list when there are no licenses", async () => {
@@ -440,10 +694,11 @@ describe("auth gating on /api/licenses", () => {
// session presence, per the flat permission model (no role/permission
// logic).
it("POST /api/licenses returns 401 without a session", async () => {
const vendor = await createTestVendor();
mockedAuth.mockResolvedValue(null);
const res = await POST(
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
postRequest({ name: "Adobe Creative Cloud", vendorId: vendor.id, seatsTotal: 10, licenseType: "subscription" })
);
expect(res.status).toBe(401);
+809
View File
@@ -0,0 +1,809 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
import {
closeMaintenanceRecord,
createMaintenanceRecord,
listMaintenanceRecords,
validateCloseMaintenanceRecordInput,
validateCreateMaintenanceRecordInput,
} from "@/lib/maintenance-records";
import { prisma } from "@/lib/prisma";
// This file has two layers of coverage, mirroring tests/assignments.test.ts:
// - lib-level tests exercise src/lib/maintenance-records.ts directly
// (validation, transactional business rules).
// - route-level tests exercise src/app/api/maintenance-records/route.ts and
// src/app/api/maintenance-records/[id]/route.ts over HTTP, including the
// auth() gate — mirrors tests/assignments.test.ts's @/auth mocking pattern.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/maintenance-records/route";
import { PATCH } from "@/app/api/maintenance-records/[id]/route";
import { auth } from "@/auth";
const mockedAuth = auth as unknown as Mock<() => Promise<Session | null>>;
const testSession: Session = {
user: { id: "test-user-id" },
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
};
function postRequest(body: unknown): Request {
return new Request("http://localhost/api/maintenance-records", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function patchRequest(body: unknown): Request {
return new Request("http://localhost/api/maintenance-records/x", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
// MaintenanceRecord.assetItemId is a real FK to AssetItem, which itself
// requires a full AssetModel -> AssetCategory chain. Mirrors
// tests/assignments.test.ts's createTestModelId() helper.
async function createTestModelId(): Promise<string> {
const category = await prisma.assetCategory.create({
data: { name: "Laptop", valueGroup: "high_value" },
});
const model = await prisma.assetModel.create({
data: { categoryId: category.id, manufacturer: "Dell", name: "Latitude 5440" },
});
return model.id;
}
let serialCounter = 0;
function createTestAssetItem(overrides: {
modelId: string;
status?: "in_stock" | "assigned" | "repair" | "retired";
}) {
serialCounter += 1;
return prisma.assetItem.create({
data: {
serialNumber: `SN-MAINT-${serialCounter}`,
modelId: overrides.modelId,
purchaseDate: new Date("2026-01-15"),
status: overrides.status ?? "in_stock",
},
});
}
// MaintenanceRecord.employeeId isn't a field on the model, but Assignment is
// used to exercise the "active assignment blocks maintenance" rule, which
// itself needs an Employee. Mirrors tests/assignments.test.ts's
// createTestEmployee() helper.
function createTestEmployee(overrides: { name?: string } = {}) {
return prisma.employee.create({
data: {
name: overrides.name ?? "Test Employee",
department: "IT",
},
});
}
// MaintenanceRecord.vendorId is a real (nullable) FK to Vendor. Mirrors
// tests/assignments.test.ts's createTestVendor() helper.
function createTestVendor(overrides: { name?: string } = {}) {
return prisma.vendor.create({
data: {
name: overrides.name ?? "Test Vendor",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Test Street",
},
});
}
beforeEach(async () => {
// Clear in FK-safe order: maintenance_records + assignments first
// (reference asset_items/licenses/vendors/employees), then asset_items/
// licenses (reference purchase_orders/vendors/models), then the rest.
await prisma.maintenanceRecord.deleteMany();
await prisma.assignment.deleteMany();
await prisma.assetItem.deleteMany();
await prisma.license.deleteMany();
await prisma.assetModel.deleteMany();
await prisma.assetCategory.deleteMany();
await prisma.purchaseOrder.deleteMany();
await prisma.vendor.deleteMany();
await prisma.employee.deleteMany();
serialCounter = 0;
// Default to a logged-in session — the lib-level tests don't go through
// the route at all (no-op for them), and the route-level tests exercise
// business logic, not auth gating (covered by the "auth gating" block).
mockedAuth.mockResolvedValue(testSession);
});
describe("validateCreateMaintenanceRecordInput", () => {
it("accepts valid input without vendorId", () => {
const result = validateCreateMaintenanceRecordInput({
assetItemId: "11111111-1111-1111-1111-111111111111",
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
});
expect(result.ok).toBe(true);
});
it("accepts valid input with vendorId set", () => {
const result = validateCreateMaintenanceRecordInput({
assetItemId: "11111111-1111-1111-1111-111111111111",
type: "warranty",
vendorId: "22222222-2222-2222-2222-222222222222",
issueDescription: "Battery swollen",
startDate: "2026-08-01",
});
expect(result.ok).toBe(true);
});
it("rejects a missing assetItemId", () => {
const result = validateCreateMaintenanceRecordInput({
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/assetItemId/i);
}
});
it("rejects an invalid type", () => {
const result = validateCreateMaintenanceRecordInput({
assetItemId: "11111111-1111-1111-1111-111111111111",
type: "not-a-type",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/type/i);
}
});
});
describe("createMaintenanceRecord", () => {
it("opens a maintenance record, flips AssetItem.status to repair", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: new Date("2026-08-01"),
});
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.asset_item_id).toBe(assetItem.id);
expect(created.value.vendor_id).toBeNull();
expect(created.value.end_date).toBeNull();
const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(updatedItem.status).toBe("repair");
});
it("creates successfully without a vendorId", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "preventive",
issueDescription: "Routine cleaning",
startDate: new Date("2026-08-01"),
});
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.vendor_id).toBeNull();
});
it("creates successfully with a vendorId", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const vendor = await createTestVendor();
const created = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "warranty",
vendorId: vendor.id,
issueDescription: "Battery swollen",
startDate: new Date("2026-08-01"),
});
expect(created.ok).toBe(true);
if (!created.ok) return;
expect(created.value.vendor_id).toBe(vendor.id);
});
it("rejects opening a record for an AssetItem that has an active assignment", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId, status: "assigned" });
const employee = await createTestEmployee();
await prisma.assignment.create({
data: { employeeId: employee.id, assetItemId: assetItem.id, assignedAt: new Date() },
});
const created = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: new Date("2026-08-01"),
});
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/active assignment/i);
const remaining = await prisma.maintenanceRecord.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects opening a record for an AssetItem that is already retired", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId, status: "retired" });
const created = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: new Date("2026-08-01"),
});
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/retired/i);
const remaining = await prisma.maintenanceRecord.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects opening a second record while one is already open for the same AssetItem", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const first = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: new Date("2026-08-01"),
});
expect(first.ok).toBe(true);
const second = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "preventive",
issueDescription: "Another issue",
startDate: new Date("2026-08-02"),
});
expect(second.ok).toBe(false);
if (second.ok) return;
expect(second.error).toMatch(/already has an open maintenance record/i);
const records = await prisma.maintenanceRecord.findMany({ where: { assetItemId: assetItem.id } });
expect(records).toHaveLength(1);
});
});
describe("closeMaintenanceRecord", () => {
it.each(["in_stock", "retired"] as const)(
"closes a record and applies targetStatus %s to the AssetItem",
async (targetStatus) => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: new Date("2026-08-01"),
});
expect(created.ok).toBe(true);
if (!created.ok) return;
const closed = await closeMaintenanceRecord(created.value.id, {
endDate: new Date("2026-08-05"),
targetStatus,
});
expect(closed.ok).toBe(true);
if (!closed.ok) return;
expect(closed.value.end_date).not.toBeNull();
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe(targetStatus);
}
);
it("rejects double-closing a record", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: new Date("2026-08-01"),
});
expect(created.ok).toBe(true);
if (!created.ok) return;
const firstClose = await closeMaintenanceRecord(created.value.id, {
endDate: new Date("2026-08-05"),
targetStatus: "in_stock",
});
expect(firstClose.ok).toBe(true);
const secondClose = await closeMaintenanceRecord(created.value.id, {
endDate: new Date("2026-08-06"),
targetStatus: "in_stock",
});
expect(secondClose.ok).toBe(false);
if (secondClose.ok) return;
expect(secondClose.status).toBe(400);
expect(secondClose.error).toMatch(/already been closed/i);
});
it("rejects an endDate before the record's startDate", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createMaintenanceRecord({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: new Date("2026-08-05"),
});
expect(created.ok).toBe(true);
if (!created.ok) return;
const closed = await closeMaintenanceRecord(created.value.id, {
endDate: new Date("2026-08-01"),
targetStatus: "in_stock",
});
expect(closed.ok).toBe(false);
if (closed.ok) return;
expect(closed.status).toBe(400);
expect(closed.error).toMatch(/endDate must be on or after startDate/i);
});
it("returns 404 for a nonexistent record id", async () => {
const result = await closeMaintenanceRecord("00000000-0000-0000-0000-000000000000", {
endDate: new Date("2026-08-05"),
targetStatus: "in_stock",
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(404);
expect(result.error).toMatch(/not found/i);
});
});
describe("validateCloseMaintenanceRecordInput", () => {
it("accepts valid input", () => {
const result = validateCloseMaintenanceRecordInput({
endDate: "2026-08-05",
targetStatus: "in_stock",
});
expect(result.ok).toBe(true);
});
it("rejects a missing targetStatus", () => {
const result = validateCloseMaintenanceRecordInput({ endDate: "2026-08-05" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/targetStatus/i);
}
});
it("rejects a missing endDate", () => {
const result = validateCloseMaintenanceRecordInput({ targetStatus: "in_stock" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/endDate/i);
}
});
});
describe("listMaintenanceRecords", () => {
it("lists created records, newest first", async () => {
const modelId = await createTestModelId();
const item1 = await createTestAssetItem({ modelId });
const item2 = await createTestAssetItem({ modelId });
const first = await createMaintenanceRecord({
assetItemId: item1.id,
type: "repair",
issueDescription: "Issue 1",
startDate: new Date("2026-08-01"),
});
expect(first.ok).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 5));
const second = await createMaintenanceRecord({
assetItemId: item2.id,
type: "repair",
issueDescription: "Issue 2",
startDate: new Date("2026-08-01"),
});
expect(second.ok).toBe(true);
const records = await listMaintenanceRecords();
expect(records).toHaveLength(2);
expect(records[0].asset_item_id).toBe(item2.id);
expect(records[1].asset_item_id).toBe(item1.id);
});
it("returns an empty list when there are no records", async () => {
const records = await listMaintenanceRecords();
expect(records).toEqual([]);
});
});
describe("POST /api/maintenance-records", () => {
it("creates a record on valid input", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const res = await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({
asset_item_id: assetItem.id,
type: "repair",
vendor_id: null,
end_date: null,
});
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("repair");
});
it("returns the full expected snake_case shape", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const vendor = await createTestVendor();
const res = await POST(
postRequest({
assetItemId: assetItem.id,
type: "warranty",
vendorId: vendor.id,
issueDescription: "Battery swollen",
startDate: "2026-08-01",
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({
id: expect.any(String),
asset_item_id: assetItem.id,
type: "warranty",
vendor_id: vendor.id,
issue_description: "Battery swollen",
resolution_notes: null,
start_date: expect.any(String),
end_date: null,
created_at: expect.any(String),
});
});
it("rejects creating a record for an AssetItem with an active assignment", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId, status: "assigned" });
const employee = await createTestEmployee();
await prisma.assignment.create({
data: { employeeId: employee.id, assetItemId: assetItem.id, assignedAt: new Date() },
});
const res = await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/active assignment/i);
});
it("rejects creating a record for a retired AssetItem", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId, status: "retired" });
const res = await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/retired/i);
});
it("rejects creating a second open record for the same AssetItem", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
})
);
const res = await POST(
postRequest({
assetItemId: assetItem.id,
type: "preventive",
issueDescription: "Another issue",
startDate: "2026-08-02",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/already has an open maintenance record/i);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/maintenance-records", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body (string)", async () => {
const res = await POST(postRequest("not an object"));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
it("rejects a non-object JSON body (number)", async () => {
const res = await POST(postRequest(123));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
});
describe("GET /api/maintenance-records", () => {
it("lists created records, newest first", async () => {
const modelId = await createTestModelId();
const item1 = await createTestAssetItem({ modelId });
const item2 = await createTestAssetItem({ modelId });
await POST(
postRequest({
assetItemId: item1.id,
type: "repair",
issueDescription: "Issue 1",
startDate: "2026-08-01",
})
);
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(
postRequest({
assetItemId: item2.id,
type: "repair",
issueDescription: "Issue 2",
startDate: "2026-08-01",
})
);
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveLength(2);
expect(body[0].asset_item_id).toBe(item2.id);
expect(body[1].asset_item_id).toBe(item1.id);
});
it("returns an empty list when there are no records", async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
it("returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
});
});
describe("PATCH /api/maintenance-records/[id]", () => {
it.each(["in_stock", "retired"] as const)(
"closes a record with targetStatus %s",
async (targetStatus) => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
})
);
const created = await createRes.json();
const res = await PATCH(
patchRequest({ endDate: "2026-08-05", targetStatus }),
{ params: { id: created.id } }
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.end_date).not.toBeNull();
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe(targetStatus);
}
);
it("returns 400 when targetStatus is missing", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
})
);
const created = await createRes.json();
const res = await PATCH(patchRequest({ endDate: "2026-08-05" }), {
params: { id: created.id },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/targetStatus/i);
});
it("rejects double-closing a record with 400", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
})
);
const created = await createRes.json();
await PATCH(patchRequest({ endDate: "2026-08-05", targetStatus: "in_stock" }), {
params: { id: created.id },
});
const res = await PATCH(patchRequest({ endDate: "2026-08-06", targetStatus: "in_stock" }), {
params: { id: created.id },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/already been closed/i);
});
it("rejects an endDate before startDate with 400", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-05",
})
);
const created = await createRes.json();
const res = await PATCH(patchRequest({ endDate: "2026-08-01", targetStatus: "in_stock" }), {
params: { id: created.id },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/endDate must be on or after startDate/i);
});
it("returns 404 for a nonexistent record id", async () => {
const res = await PATCH(patchRequest({ endDate: "2026-08-05", targetStatus: "in_stock" }), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toMatch(/not found/i);
});
it("returns 400 for a malformed id (not a UUID)", async () => {
const res = await PATCH(patchRequest({ endDate: "2026-08-05", targetStatus: "in_stock" }), {
params: { id: "not-a-uuid" },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/UUID/i);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/maintenance-records/x", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await PATCH(req, { params: { id: "00000000-0000-0000-0000-000000000000" } });
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body (string)", async () => {
const res = await PATCH(patchRequest("not an object"), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
it("rejects a non-object JSON body (number)", async () => {
const res = await PATCH(patchRequest(123), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
it("returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await PATCH(patchRequest({ endDate: "2026-08-05", targetStatus: "in_stock" }), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
});
});
describe("auth gating on /api/maintenance-records", () => {
it("POST /api/maintenance-records returns 401 without a session", async () => {
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
mockedAuth.mockResolvedValue(null);
const res = await POST(
postRequest({
assetItemId: assetItem.id,
type: "repair",
issueDescription: "Screen flickers",
startDate: "2026-08-01",
})
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
const remaining = await prisma.maintenanceRecord.findMany();
expect(remaining).toHaveLength(0);
});
});
+577
View File
@@ -0,0 +1,577 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
import {
createPurchaseOrder,
listPurchaseOrders,
validateCreatePurchaseOrderInput,
} from "@/lib/purchase-orders";
import { prisma } from "@/lib/prisma";
// Note: this file has two layers of coverage, mirroring tests/licenses.test.ts:
// - lib-level tests below exercise src/lib/purchase-orders.ts directly
// (validation, co-nullability refine, FK error handling, list ordering).
// - route-level tests (further down) exercise
// src/app/api/purchase-orders/route.ts over HTTP, including the auth() gate.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/purchase-orders/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/purchase-orders", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
// PurchaseOrder.vendorId/receivedById are real FKs to Vendor/Employee (see
// schema.prisma / src/lib/purchase-orders.ts) — tests that need valid ids
// create real rows first and use their ids. Mirrors
// tests/licenses.test.ts's createTestVendor() and tests/requests.test.ts's
// createTestEmployee() helpers.
function createTestVendor(overrides: { name?: string } = {}) {
return prisma.vendor.create({
data: {
name: overrides.name ?? "Test Vendor",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Test Street",
},
});
}
function createTestEmployee(overrides: { name?: string; department?: string } = {}) {
return prisma.employee.create({
data: {
name: overrides.name ?? "Test Employee",
department: overrides.department ?? "IT",
},
});
}
beforeEach(async () => {
// Each test starts from empty tables so listing/order assertions are
// deterministic. purchase_orders must be cleared before vendors/employees
// since purchase_orders has FKs (ON DELETE RESTRICT to vendors, ON DELETE
// SET NULL to employees) to both.
await prisma.purchaseOrder.deleteMany();
await prisma.vendor.deleteMany();
await prisma.employee.deleteMany();
// Default to a logged-in session — the lib-level tests below don't go
// through the route at all (so this is a no-op for them), and the
// route-level tests exercise business logic, not auth gating (that's
// covered by the "auth gating" describe block further down).
mockedAuth.mockResolvedValue(testSession);
});
describe("validateCreatePurchaseOrderInput", () => {
it("accepts valid input with no receivedAt/receivedById (not yet received)", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "11111111-1111-1111-1111-111111111111",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.vendorId).toBe("11111111-1111-1111-1111-111111111111");
expect(result.value.orderDate).toBeInstanceOf(Date);
expect(result.value.expectedDate).toBeInstanceOf(Date);
expect(result.value.receivedAt).toBeUndefined();
expect(result.value.receivedById).toBeUndefined();
}
});
it("accepts valid input with both receivedAt and receivedById explicitly null (not yet received)", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "11111111-1111-1111-1111-111111111111",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: null,
receivedById: null,
});
expect(result.ok).toBe(true);
});
it("accepts valid input with both receivedAt and receivedById set (received)", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "11111111-1111-1111-1111-111111111111",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: "2026-08-12",
receivedById: "22222222-2222-2222-2222-222222222222",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.receivedAt).toBeInstanceOf(Date);
expect(result.value.receivedById).toBe("22222222-2222-2222-2222-222222222222");
}
});
it("rejects receivedAt set without receivedById", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "11111111-1111-1111-1111-111111111111",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: "2026-08-12",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/receivedAt/i);
expect(result.error).toMatch(/receivedById/i);
}
});
it("rejects receivedById set without receivedAt", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "11111111-1111-1111-1111-111111111111",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedById: "22222222-2222-2222-2222-222222222222",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/receivedAt/i);
expect(result.error).toMatch(/receivedById/i);
}
});
it("rejects a missing vendorId", () => {
const result = validateCreatePurchaseOrderInput({
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/vendorId/i);
}
});
it("rejects a vendorId that is not a valid UUID", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "not-a-uuid",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/vendorId/i);
}
});
it("rejects a missing orderDate", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "11111111-1111-1111-1111-111111111111",
expectedDate: "2026-08-10",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/orderDate/i);
}
});
it("rejects an invalid expectedDate", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "11111111-1111-1111-1111-111111111111",
orderDate: "2026-08-01",
expectedDate: "not-a-date",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/expectedDate/i);
}
});
it("rejects a receivedById that is not a valid UUID", () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "11111111-1111-1111-1111-111111111111",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: "2026-08-12",
receivedById: "not-a-uuid",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/receivedById/i);
}
});
});
describe("createPurchaseOrder", () => {
it("creates a purchase order that is not yet received", async () => {
const vendor = await createTestVendor();
const result = validateCreatePurchaseOrderInput({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createPurchaseOrder(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
const record = created.value;
expect(record.vendor_id).toBe(vendor.id);
expect(record.received_at).toBeNull();
expect(record.received_by).toBeNull();
expect(typeof record.id).toBe("string");
expect(record.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(record.order_date))).toBe(false);
expect(Number.isNaN(Date.parse(record.expected_date))).toBe(false);
expect(Number.isNaN(Date.parse(record.created_at))).toBe(false);
});
it("creates a purchase order that has already been received", async () => {
const vendor = await createTestVendor();
const employee = await createTestEmployee();
const result = validateCreatePurchaseOrderInput({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: "2026-08-12",
receivedById: employee.id,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createPurchaseOrder(result.value);
expect(created.ok).toBe(true);
if (!created.ok) return;
const record = created.value;
expect(record.received_by).toBe(employee.id);
expect(Number.isNaN(Date.parse(record.received_at as string))).toBe(false);
});
it("returns a clean error when vendorId doesn't reference an existing vendor", async () => {
const result = validateCreatePurchaseOrderInput({
vendorId: "00000000-0000-0000-0000-000000000000",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createPurchaseOrder(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/vendorId/i);
expect(created.error).toMatch(/does not reference an existing vendor/i);
const remaining = await prisma.purchaseOrder.findMany();
expect(remaining).toHaveLength(0);
});
it("returns a clean error when receivedById doesn't reference an existing employee", async () => {
const vendor = await createTestVendor();
const result = validateCreatePurchaseOrderInput({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: "2026-08-12",
receivedById: "00000000-0000-0000-0000-000000000000",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const created = await createPurchaseOrder(result.value);
expect(created.ok).toBe(false);
if (created.ok) return;
expect(created.error).toMatch(/receivedById/i);
expect(created.error).toMatch(/does not reference an existing employee/i);
const remaining = await prisma.purchaseOrder.findMany();
expect(remaining).toHaveLength(0);
});
});
describe("listPurchaseOrders", () => {
it("lists created purchase orders, newest first", async () => {
const vendor1 = await createTestVendor({ name: "Microsoft VN" });
const vendor2 = await createTestVendor({ name: "Adobe VN" });
const first = validateCreatePurchaseOrderInput({
vendorId: vendor1.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
});
expect(first.ok).toBe(true);
if (first.ok) await createPurchaseOrder(first.value);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
const second = validateCreatePurchaseOrderInput({
vendorId: vendor2.id,
orderDate: "2026-08-02",
expectedDate: "2026-08-11",
});
expect(second.ok).toBe(true);
if (second.ok) await createPurchaseOrder(second.value);
const records = await listPurchaseOrders();
expect(records).toHaveLength(2);
// newest first
expect(records[0]).toMatchObject({ vendor_id: vendor2.id });
expect(records[1]).toMatchObject({ vendor_id: vendor1.id });
});
it("returns an empty list when there are no purchase orders", async () => {
const records = await listPurchaseOrders();
expect(records).toEqual([]);
});
});
describe("POST /api/purchase-orders", () => {
it("creates a purchase order that is not yet received on valid input", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({ vendor_id: vendor.id, received_at: null, received_by: null });
expect(typeof body.id).toBe("string");
expect(body.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(body.order_date))).toBe(false);
expect(Number.isNaN(Date.parse(body.expected_date))).toBe(false);
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
});
it("creates a purchase order that has already been received", async () => {
const vendor = await createTestVendor();
const employee = await createTestEmployee();
const res = await POST(
postRequest({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: "2026-08-12",
receivedById: employee.id,
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.received_by).toBe(employee.id);
expect(Number.isNaN(Date.parse(body.received_at))).toBe(false);
});
it("rejects a missing vendorId", async () => {
const res = await POST(
postRequest({ orderDate: "2026-08-01", expectedDate: "2026-08-10" })
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/vendorId/i);
});
it("rejects a vendorId that is a well-formed UUID but doesn't reference an existing vendor", async () => {
const res = await POST(
postRequest({
vendorId: "00000000-0000-0000-0000-000000000000",
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/vendorId/i);
expect(body.error).toMatch(/does not reference an existing vendor/i);
// And it must not have created anything.
const remaining = await prisma.purchaseOrder.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a receivedById that is a well-formed UUID but doesn't reference an existing employee", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: "2026-08-12",
receivedById: "00000000-0000-0000-0000-000000000000",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/receivedById/i);
expect(body.error).toMatch(/does not reference an existing employee/i);
// And it must not have created anything.
const remaining = await prisma.purchaseOrder.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects receivedAt set without receivedById", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedAt: "2026-08-12",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/receivedAt/i);
expect(body.error).toMatch(/receivedById/i);
});
it("rejects receivedById set without receivedAt", async () => {
const vendor = await createTestVendor();
const employee = await createTestEmployee();
const res = await POST(
postRequest({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "2026-08-10",
receivedById: employee.id,
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/receivedAt/i);
expect(body.error).toMatch(/receivedById/i);
});
it("rejects an invalid expectedDate", async () => {
const vendor = await createTestVendor();
const res = await POST(
postRequest({
vendorId: vendor.id,
orderDate: "2026-08-01",
expectedDate: "not-a-date",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/expectedDate/i);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/purchase-orders", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body", async () => {
// Note: arrays are `typeof "object"` in JS, so they pass the route's
// object check (same behavior as /api/licenses) — use a JSON string
// primitive here to actually exercise the "not an object" branch.
const res = await POST(postRequest("not an object"));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
});
describe("GET /api/purchase-orders", () => {
it("lists created purchase orders, newest first", async () => {
const vendor1 = await createTestVendor({ name: "Microsoft VN" });
const vendor2 = await createTestVendor({ name: "Adobe VN" });
await POST(
postRequest({ vendorId: vendor1.id, orderDate: "2026-08-01", expectedDate: "2026-08-10" })
);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(
postRequest({ vendorId: vendor2.id, orderDate: "2026-08-02", expectedDate: "2026-08-11" })
);
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body).toHaveLength(2);
// newest first
expect(body[0]).toMatchObject({ vendor_id: vendor2.id });
expect(body[1]).toMatchObject({ vendor_id: vendor1.id });
});
it("returns an empty list when there are no purchase orders", async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
});
describe("auth gating on /api/purchase-orders", () => {
// Mirrors tests/licenses.test.ts's "auth gating" block — only checks
// session presence, per the flat permission model (no role/permission
// logic).
it("POST /api/purchase-orders returns 401 without a session", async () => {
const vendor = await createTestVendor();
mockedAuth.mockResolvedValue(null);
const res = await POST(
postRequest({ vendorId: vendor.id, orderDate: "2026-08-01", expectedDate: "2026-08-10" })
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
// And it must not have created anything.
const remaining = await prisma.purchaseOrder.findMany();
expect(remaining).toHaveLength(0);
});
it("GET /api/purchase-orders 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" });
});
});
+124 -2
View File
@@ -50,12 +50,44 @@ function createTestEmployee(overrides: { name?: string; department?: string } =
});
}
// Request.purchaseOrderId is a real FK to PurchaseOrder (see schema.prisma /
// src/lib/requests.ts) — tests that need a valid purchase_order_id create a
// real PurchaseOrder row (and the Vendor it requires) first and use its id.
// Mirrors tests/licenses.test.ts's createTestVendor()/createTestPurchaseOrder()
// helpers.
function createTestVendor(overrides: { name?: string } = {}) {
return prisma.vendor.create({
data: {
name: overrides.name ?? "Test Vendor",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Test Street",
},
});
}
function createTestPurchaseOrder(overrides: { vendorId?: string } = {}) {
return prisma.purchaseOrder.create({
data: {
vendorId: overrides.vendorId as string,
orderDate: new Date("2026-08-01"),
expectedDate: new Date("2026-08-10"),
},
});
}
beforeEach(async () => {
// Each test starts from empty tables so listing/order assertions are
// deterministic. requests must be cleared before employees since
// requests.employee_id has an FK (ON DELETE RESTRICT) to employees.
// deterministic. requests must be cleared before employees/purchase_orders
// since requests.employee_id has an FK (ON DELETE RESTRICT) to employees
// and requests.purchase_order_id has an FK (ON DELETE SET NULL) to
// purchase_orders. purchase_orders must be cleared before employees/vendors
// (ON DELETE RESTRICT to vendors, mirrors tests/licenses.test.ts) — order:
// requests -> purchase_orders -> employees -> vendors.
await prisma.request.deleteMany();
await prisma.purchaseOrder.deleteMany();
await prisma.employee.deleteMany();
await prisma.vendor.deleteMany();
// Default to a logged-in session — the tests below exercise request
// business logic, not auth gating (that's covered by the "auth gating"
// describe block further down).
@@ -141,6 +173,96 @@ describe("POST /api/requests", () => {
const remaining = await prisma.request.findMany();
expect(remaining).toHaveLength(0);
});
it("creates a request with a purchase_order_id that references a real purchase order", async () => {
const employee = await createTestEmployee();
const vendor = await createTestVendor();
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
const res = await POST(
postRequest({
employee_id: employee.id,
type: "asset",
reason: "New laptop for onboarding",
purchase_order_id: purchaseOrder.id,
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.purchase_order_id).toBe(purchaseOrder.id);
});
it("creates a request with no purchase_order_id (stays null)", async () => {
const employee = await createTestEmployee();
const res = await POST(
postRequest({ employee_id: employee.id, type: "asset", reason: "New laptop for onboarding" })
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.purchase_order_id).toBeNull();
});
it("rejects a purchase_order_id that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
const employee = await createTestEmployee();
const res = await POST(
postRequest({
employee_id: employee.id,
type: "asset",
reason: "New laptop for onboarding",
purchase_order_id: "00000000-0000-0000-0000-000000000000",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/purchase_order_id/i);
expect(body.error).toMatch(/does not reference an existing purchase order/i);
// And it must not have created anything.
const remaining = await prisma.request.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a purchase_order_id that is not a valid UUID", async () => {
const employee = await createTestEmployee();
const res = await POST(
postRequest({
employee_id: employee.id,
type: "asset",
reason: "New laptop for onboarding",
purchase_order_id: "not-a-uuid",
})
);
expect(res.status).toBe(400);
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
View File
@@ -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)`)
);
+476
View File
@@ -0,0 +1,476 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
import {
createVendor,
listVendors,
validateCreateVendorInput,
} from "@/lib/vendors";
import { prisma } from "@/lib/prisma";
// Note: this file has two layers of coverage, mirroring tests/licenses.test.ts:
// - lib-level tests below exercise src/lib/vendors.ts directly (validation,
// nullable taxCode/notes, list ordering).
// - route-level tests (further down) exercise src/app/api/vendors/route.ts
// over HTTP, including the auth() gate — mirrors tests/licenses.test.ts's
// @/auth mocking pattern.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/vendors/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/vendors", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(async () => {
// Each test starts from an empty table so listing/order assertions are
// deterministic.
await prisma.vendor.deleteMany();
// Default to a logged-in session — the lib-level tests below don't go
// through the route at all (so this is a no-op for them), and the
// route-level tests exercise business logic, not auth gating (that's
// covered by the "auth gating" describe block further down).
mockedAuth.mockResolvedValue(testSession);
});
describe("validateCreateVendorInput", () => {
it("accepts valid input with taxCode and notes", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: "0301234567",
notes: "Preferred reseller for Dell laptops",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toMatchObject({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: "0301234567",
notes: "Preferred reseller for Dell laptops",
});
}
});
it("accepts valid input with no taxCode/notes", () => {
const result = validateCreateVendorInput({
name: "Synnex FPT",
contactEmail: "[email protected]",
contactPhone: "0287654321",
address: "456 Le Van Viet, Q9, HCMC",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.taxCode).toBeUndefined();
expect(result.value.notes).toBeUndefined();
}
});
it("rejects an empty name", () => {
const result = validateCreateVendorInput({
name: "",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/name/i);
}
});
it("rejects an invalid contactEmail", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "not-an-email",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/contactEmail/i);
}
});
it("rejects a missing contactEmail", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/contactEmail/i);
}
});
it("rejects an empty contactPhone", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "",
address: "123 Nguyen Van Linh, Q7, HCMC",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/contactPhone/i);
}
});
it("rejects an empty address", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/address/i);
}
});
it("rejects an empty taxCode when provided", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: "",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/taxCode/i);
}
});
it("accepts a null taxCode/notes explicitly", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: null,
notes: null,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.taxCode).toBeNull();
expect(result.value.notes).toBeNull();
}
});
});
describe("createVendor", () => {
it("creates a vendor with taxCode and notes", async () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: "0301234567",
notes: "Preferred reseller for Dell laptops",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const record = await createVendor(result.value);
expect(record).toMatchObject({
name: "FPT Trading",
contact_email: "[email protected]",
contact_phone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
tax_code: "0301234567",
notes: "Preferred reseller for Dell laptops",
});
expect(typeof record.id).toBe("string");
expect(record.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(record.created_at))).toBe(false);
});
it("creates a vendor with no taxCode/notes (both null)", async () => {
const result = validateCreateVendorInput({
name: "Synnex FPT",
contactEmail: "[email protected]",
contactPhone: "0287654321",
address: "456 Le Van Viet, Q9, HCMC",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const record = await createVendor(result.value);
expect(record.tax_code).toBeNull();
expect(record.notes).toBeNull();
});
});
describe("listVendors", () => {
it("lists created vendors, newest first", async () => {
const first = validateCreateVendorInput({
name: "Synnex FPT",
contactEmail: "[email protected]",
contactPhone: "0287654321",
address: "456 Le Van Viet, Q9, HCMC",
});
expect(first.ok).toBe(true);
if (first.ok) await createVendor(first.value);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
const second = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
});
expect(second.ok).toBe(true);
if (second.ok) await createVendor(second.value);
const records = await listVendors();
expect(records).toHaveLength(2);
// newest first
expect(records[0]).toMatchObject({ name: "FPT Trading" });
expect(records[1]).toMatchObject({ name: "Synnex FPT" });
});
it("returns an empty list when there are no vendors", async () => {
const records = await listVendors();
expect(records).toEqual([]);
});
});
describe("POST /api/vendors", () => {
it("creates a vendor on valid input", async () => {
const res = await POST(
postRequest({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: "0301234567",
notes: "Preferred reseller for Dell laptops",
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({
name: "FPT Trading",
contact_email: "[email protected]",
contact_phone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
tax_code: "0301234567",
notes: "Preferred reseller for Dell laptops",
});
expect(typeof body.id).toBe("string");
expect(body.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
});
it("creates a vendor with no taxCode/notes", async () => {
const res = await POST(
postRequest({
name: "Synnex FPT",
contactEmail: "[email protected]",
contactPhone: "0287654321",
address: "456 Le Van Viet, Q9, HCMC",
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.tax_code).toBeNull();
expect(body.notes).toBeNull();
});
it("rejects an empty name", async () => {
const res = await POST(
postRequest({
name: "",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/name/i);
});
it("rejects an invalid contactEmail", async () => {
const res = await POST(
postRequest({
name: "FPT Trading",
contactEmail: "not-an-email",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/contactEmail/i);
});
it("rejects an empty contactPhone", async () => {
const res = await POST(
postRequest({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "",
address: "123 Nguyen Van Linh, Q7, HCMC",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/contactPhone/i);
});
it("rejects an empty address", async () => {
const res = await POST(
postRequest({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/address/i);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/vendors", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body", async () => {
// Note: arrays are `typeof "object"` in JS, so they pass the route's
// object check (same behavior as /api/licenses) — use a JSON string
// primitive here to actually exercise the "not an object" branch.
const res = await POST(postRequest("not an object"));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
});
describe("GET /api/vendors", () => {
it("lists created vendors, newest first", async () => {
await POST(
postRequest({
name: "Synnex FPT",
contactEmail: "[email protected]",
contactPhone: "0287654321",
address: "456 Le Van Viet, Q9, HCMC",
})
);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(
postRequest({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
})
);
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body).toHaveLength(2);
// newest first
expect(body[0]).toMatchObject({ name: "FPT Trading" });
expect(body[1]).toMatchObject({ name: "Synnex FPT" });
});
it("returns an empty list when there are no vendors", async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
});
describe("auth gating on /api/vendors", () => {
// Mirrors tests/licenses.test.ts's "auth gating" block — only checks
// session presence, per the flat permission model (no role/permission
// logic).
it("POST /api/vendors returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await POST(
postRequest({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
})
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
// And it must not have created anything.
const remaining = await prisma.vendor.findMany();
expect(remaining).toHaveLength(0);
});
it("GET /api/vendors 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" });
});
});
+17 -13
View File
@@ -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.
},
});