Author SHA1 Message Date
toanhoang 03d74d69c1 Rename AssetItemStatus 'assigned' to 'in_use'
CI / test (push) Failing after 9s
'assigned' implied a specific person always holds the item, but
createAssignment() only requires status=in_stock to hand something out --
the status value itself never guaranteed a matching Assignment row exists.
~158 of the 613 real-imported assets carry this status with no Assignment
at all (shared/unowned equipment like meeting-room gear), so "assigned"
was actively misleading for them. 'in_use' doesn't carry that implication.

DB: `ALTER TYPE "AssetItemStatus" RENAME VALUE 'assigned' TO 'in_use'`
(hand-written migration -- runs cleanly, no data migration needed, every
existing row's status just reads under the new name). Renamed across:
schema.prisma enum, src/lib/assets.ts (type union + IMPORT_STATUS_MAP,
which keeps an `assigned` key mapped to `in_use` for backward-compat with
pre-2026-08-25 exports/CSVs), src/lib/assignments.ts (the literal status
value createAssignment() sets), src/app/assets/page.tsx + api/assets/
route.ts (filter dropdown + validStatuses), src/app/maintenance-records/
page.tsx (its own copy of the status list for the "close" targetStatus
picker). maintenance-records.ts itself needed no logic change -- its
closeableTargetStatusValues was already just ["in_stock", "retired"],
"assigned" was never a valid close target, only mentioned in comments
(updated for accuracy).

Also fixes a real regression found while verifying this: tests/
assets.test.ts (a pre-existing, properly-reviewed test file, not written
this session) broke 2 commits ago when GET /api/assets's signature and
response shape changed for pagination -- it still called GET() with no
Request and expected a bare array. tsc didn't catch this until now
because this container has no dev bind mount (Dockerfile is COPY-based,
`docker exec` only reflects the last build) -- see the
ubi-dan-itam-docker-no-bind-mount memory note. Added a getRequest()
helper and updated assertions to the {items, total} shape.

Verified by actually rebuilding and running the full suite fresh (not
just tsc/lint smoke checks): 369/369 tests pass, including
assignments.test.ts and maintenance-records.test.ts, which this rename
touches directly. tsc/lint clean against the rebuilt image. Migration
applied to the real DB, data verified intact (542 in_use + 71 in_stock =
613, matches pre-rename counts exactly).
2026-08-24 21:57:33 -04:00
toanhoang 10508f9d04 Server-side pagination for /assets (50/page), fix duplicated manufacturer in model labels
CI / test (push) Failing after 8s
listAssetItems() now takes {page, pageSize, status, modelId, categoryId,
areaId, search} and returns {items, total} instead of the whole table --
was a bare unpaginated findMany(), shipping all ~613 rows on every page
load. Filtering moved server-side into the same WHERE clause so 'total'
reflects the filtered count, not the table's overall size -- pagination
controls stay correct regardless of which filters are active. 'search'
matches serialNumber, assetTag, or the current (non-returned) assignee's
name via the assignments relation in one query.

GET /api/assets is now the one entity route whose response is not a bare
array (see use-api-list.ts) -- its {items, total} shape needs 'total' for
page controls, so /assets fetches it directly instead of through
useApiList. Query params: page, pageSize, status, modelId, categoryId,
areaId, search.

/assets page: search is debounced 300ms (was firing a request per
keystroke otherwise), changing any filter resets to page 1, added
Previous/Next controls + "showing X-Y / total" count. Row # is now a
running index across pages, not per-page.

Separately: all 135 AssetModel rows from the 2026-08-24 real-data import
have the manufacturer name baked into 'name' already (the source file's
own Model column did, e.g. name="Bose Soundlink Revolve" manufacturer=
"Bose") -- rendering "manufacturer name" everywhere doubled it
("Bose Bose Soundlink Revolve"). Fixed at the display layer
(formatModelLabel(), skips the manufacturer prefix when name already
starts with it) rather than a data migration -- non-destructive, handles
every affected row uniformly, reversible.

Smoke-tested listAssetItems() directly (tsx): page1/page2 have zero
overlap, status filter count matches (71 in_stock), asset-tag search
finds exactly 1 row, assignee-name search correctly joins through
Assignment (4 rows for one employee), last page (13 of 613/50) has the
expected remainder of 13 rows.

tsc/lint clean, no Vitest coverage -- same deviation as the rest of this
branch, see CLAUDE.md.
2026-08-24 21:31:22 -04:00
toanhoang 4063b81f16 Add AssetItem.areaId, show assigned-to + area on /assets, mm/dd/yyyy dates
CI / test (push) Failing after 9s
AssetItem.areaId reuses the existing Area entity (built for InventoryScan/
the office-move feature) rather than a free-text location string -- same
share-the-lookup-entity precedent floated for Department. Real-world
coverage from the ITAM export is thin: Physical location was the literal
string DAN for all 613 rows (useless), only Stockroom carried signal, and
only for the ~71 in_stock items -- an assigned item is with a person, not
in a stockroom bin, so most rows have no area by design, not by gap.

/assets page: the assigned employee (via the active, non-returned
Assignment) was already in the DB from the 2026-08-24 backfill but never
shown -- added an Assigned To column, folded into the free-text search
too. Added an Area column + filter dropdown. Removed the Created At
column (not actionable) and format Purchase Date/Warranty End Date as
mm/dd/yyyy instead of raw ISO strings.

CSV import/export: area column added (resolved/created via
createOrGetArea(), same idempotent-by-name helper the bots Area picker
uses -- not a raw UUID). Re-ran the 613-row import after this: 0 created,
613 updated, 0 errors -- backfilled area onto the 71 rows with a
Stockroom value (46 IT Cabinet, 21 Stockroom, 4 For allocation), verified
against the source file.

Hand-written migration, no Vitest coverage -- same deviation as the rest
of this branch, see CLAUDE.md. tsc/lint clean.
2026-08-24 21:05:03 -04:00
toanhoang 6863323cce Add AssetItemStatus.lost, category filter on /assets page
CI / test (push) Failing after 8s
lost added via ALTER TYPE ... ADD VALUE (hand-written migration, stays an
enum -- see the schema.prisma comment on why: status values are read
directly by business logic like createAssignment()s in_stock gate, a
lookup table would not actually decouple that). IMPORT_STATUS_MAP gains
lost/missing -> lost mappings for CSV import/re-sync.

/assets page gets a Category filter dropdown (alongside the existing
Status/Model/search filters) that also narrows the Model dropdown to that
category -- with 135 models after the 2026-08-24 real-data import, an
unfiltered Model list is unwieldy. Picking a Category resets the Model
filter if the currently-selected model does not belong to it.

tsc/lint clean, no Vitest coverage -- same deviation as the rest of this
branch, see CLAUDE.md.
2026-08-24 20:41:50 -04:00
toanhoang 3ebd7efd7f Assets page: show model name instead of UUID, row index instead of raw ID, add filters
CI / test (push) Failing after 8s
Model ID column now shows "Manufacturer Name" (looked up from the
already-fetched asset-models list) instead of the raw model_id UUID.
The ID column shows a 1-based row index within the current filtered view
instead of the AssetItem UUID -- the id itself was never actionable in
the UI (no per-row link/detail view exists), just clutter at this scale
(~600+ rows after the real-data import).

Added a filter bar: free-text search (matches serial_number or asset_tag,
case-insensitive substring), status dropdown, model dropdown -- all
client-side (assetItems is already the full list via useApiList, and
filtering ~600 rows in memory is trivial, no need for a server-side
filtered endpoint at this scale).

tsc/lint clean, no Vitest coverage -- same deviation as the rest of this
branch, see CLAUDE.md.
2026-08-24 20:28:30 -04:00
toanhoang 2cbe746bde Add AssetItem.cost/costCurrency/externalPoNumber, make import upsert-capable
CI / test (push) Failing after 9s
Three new fields: cost (Decimal, not Float -- exact money math),
costCurrency (free text), externalPoNumber (free text PO number from an
external system, distinct from purchaseOrderId which is a real FK into
this apps own PurchaseOrder table -- external exports PO numbers are
messy free text with nothing to link to here).

importAssetItems() now upserts: a row matching an existing assetTag or
serialNumber updates that row instead of erroring on the uniqueness
conflict. Needed to backfill cost/PO onto the 613 already-imported rows
without recreating them, and sets this up for future re-syncs when the
company re-exports their ITAM report. Update only touches fields the row
actually carries a value for (blank cell = no change, not clear) --
warrantyEndDate is the one field NOT auto-recomputed from warrantyMonths
on update (unlike create), so a manually-corrected date survives a
re-sync. Placeholder junk (Not available/Pending/N/A/etc, case
-insensitive) in PO number/cost/currency cells is treated as absent.

Re-ran the full 613-row import after this: 0 created, 613 updated, 0
errors -- backfilled cost onto the 8 rows that had one and PO number onto
the 2 that had a real (non-placeholder) value, verified against the
source file.

Hand-written migration, no Vitest coverage -- same deviation as the rest
of this branch, see CLAUDE.md. tsc/lint clean.
2026-08-23 22:14:48 -04:00
toanhoang 1360bb3c45 docs: record real Employee/Assignment backfill (2026-08-24)
CI / test (push) Failing after 8s
No code changes -- this backfilled 95 Employee + 384 Assignment rows from
the real ITAM export using existing Prisma models directly (not
src/lib/assignments.tss createAssignment(), which is for the live
issue-from-stock workflow and would reject every row here since these
AssetItems already carry status=assigned from the earlier bulk import).
See CLAUDE.md for the full rationale and whats still out of scope
(Cost, PO number, Employee.job/manager/email).
2026-08-23 21:50:37 -04:00
toanhoang 69437cf38f Add AssetItem.assetTag, status-aware bulk import, nullable purchaseDate
CI / test (push) Failing after 8s
Prep work for importing a real company ITAM export (613 real assets):

- AssetItem.assetTag (nullable, unique) -- company physical tag, was only
  on InventoryScan (move feature) before, now on the actual asset record.
- AssetItem.purchaseDate is now nullable (was required) -- some legacy
  rows genuinely have no known purchase date. Manual /assets create form
  still requires it in the HTML input; this only relaxes the DB/import
  path for historical data.
- importAssetItems() accepts a status column now (free-text "In use"/
  "In stock"/etc mapped via IMPORT_STATUS_MAP), unlike manual creation
  which always defaults in_stock -- bulk-imported inventory is usually
  already deployed, not freshly purchased, so forcing in_stock would
  misrepresent reality.

Hand-written migrations (asset_tag, purchase_date nullable), no Vitest
coverage -- same deviation as the rest of this branch, see CLAUDE.md.
tsc/lint clean.

Actually imported 613/613 real AssetItem rows from the export with 0
errors after this (not committed here -- thats data, not code; see
CLAUDE.md for the seeding/import process and whats deliberately out of
scope this round: Cost, PO number, employee Assignment).
2026-08-23 21:33:48 -04:00
toanhoang aa7f5515db Add AssetItem CSV import/export
CI / test (push) Failing after 8s
/assets gets Export CSV (GET /api/assets/export) and Import CSV
(POST /api/assets/import) buttons. Export emits human-readable
manufacturer/model columns instead of raw modelId; import resolves those
back to an AssetModel (case-insensitive match, must already exist -- this
does not auto-create models) so export -> edit in Excel -> re-import is a
real round trip.

Per-row import errors (unmatched model, missing required field, bad date)
are collected and returned alongside the created count rather than
aborting the whole file on the first bad row -- a typo in row 200 should
not cost rows 1-199. warrantyEndDate still auto-fills from the models
warrantyMonths when the CSV omits it, same as manual creation.

Extracted src/lib/csv.ts (splitCsvLine/splitCsvLines/normalizeHeader/
csvField) out of expected-assets.ts so both CSV parsers share one
implementation instead of duplicating it.

Manual smoke test only (tsx script: export shape, valid row + unmatched-
model row + missing-date row all handled correctly), no Vitest coverage,
not through the ba->pm->feature-implementer->test-runner->code-reviewer
pipeline -- same deviation as the rest of this branch, see CLAUDE.md.
tsc/lint clean.
2026-08-23 07:41:52 -04:00
toanhoang d9b360c1df Auto-fill AssetItem.warrantyEndDate from AssetModel.warrantyMonths
CI / test (push) Failing after 8s
AssetModel gains an optional warrantyMonths (standard vendor warranty
length). createAssetItem() now defaults warrantyEndDate to purchaseDate +
warrantyMonths (month-end clamped, e.g. Dec 31 + 2mo -> Feb 28, not Mar 3)
whenever the caller omits warrantyEndDate -- still overridable per item
for batches with extended/different warranty terms. /asset-models UI gets
an input for the new field; /assets needs no change since it already
omits warrantyEndDate from the payload when left blank.

Hand-written migration + manual smoke test (tsx script, not Vitest),
same deviation as the other 2026-08-2x work in this branch -- see
CLAUDE.md. tsc/lint clean.
2026-08-21 09:10:55 -04:00
toanhoang 8ac95c974b Add ExpectedAsset CSV import + bot search/pick for pre-known inventory
CI / test (push) Failing after 8s
Lets the operator upload a CSV of already-known assets (asset tag, model,
serial, old area/assignee) before an office-move walk, via a new
session-authed /inventory-scans/import page. The Telegram bot gains a
"tim trong list" search step at the asset-tag prompt: picking a match
pre-fills tag/serial and offers one-tap "giu nguyen" buttons for the
old area/assignee, then marks that row matched once the scan is created.

Follows the same-day decision to turn off local OCR (qwen2.5vl via Ollama)
for this feature -- see CLAUDE.md and the ITAM/Ollama vault notes for the
accuracy/speed numbers behind that call.

Deviates from repo convention same as the InventoryScan work above: no
Vitest coverage (manual smoke test only, via simulated webhook calls),
hand-written migration, not through the ba->pm->feature-implementer->
test-runner->code-reviewer pipeline. tsc/lint are clean.
2026-08-21 08:59:18 -04:00
toanhoangandClaude Sonnet 5 b4e2f6c83e docs: note decision to merge inventory-capture bot into this app
CI / test (push) Failing after 9s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-20 09:47:33 -04:00
toanhoangandClaude Sonnet 5 b52106dfa6 fix(ci): use db service hostname instead of localhost for Postgres
CI / test (push) Successful in 4m20s
self-hosted runner is pinned to homelab_proxy network (needed to resolve the gitea host for checkout), so job+service containers are separate network members, not sharing a netns like GitHub-hosted runners service containers do -- localhost inside the job container never reached the db service. Also drops the now-unneeded host port publish (avoids repeating the port-already-allocated failure an earlier orphaned run left behind).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-19 22:48:19 -04:00
toanhoangandClaude Sonnet 5 d7c0da2328 ci: retrigger #3 (cleared orphaned container holding port 5432)
CI / test (push) Failing after 12m29s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-19 22:31:51 -04:00
toanhoangandClaude Sonnet 5 555fbf64fd ci: retrigger #2
CI / test (push) Failing after 8s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-19 22:30:27 -04:00
toanhoangandClaude Sonnet 5 5a8dea2ea3 ci: retrigger after runner network + resource-limit fix
CI / test (push) Failing after 7s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-19 22:25:05 -04:00
toanhoangandClaude Sonnet 5 acc79a1d3c chore: add Gitea Actions CI workflow
CI / test (push) Failing after 16m22s
npm ci -> prisma generate -> tsc --noEmit -> lint -> test (Postgres service) -> build, on every push/PR. Requires a self-hosted act_runner registered to git.toanhoang.work (set up 2026-08-20, see /opt/homelab/gitea-runner/ on homelab01 -- outside this repo). No auto-deploy yet, still manual on appsrv01.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-19 22:07:42 -04:00
toanhoangandClaude Sonnet 5 9773dd8a39 docs: document InventoryScan feature + note deviation from agent pipeline
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-19 22:07:39 -04:00
toanhoangandClaude Sonnet 5 84e6db7406 test: add Area, InventoryScan and bot API tests
369/369 tests pass (35 new), tsc --noEmit clean, next lint clean. Not yet run through the repos code-reviewer subagent gate (this session operated via SSH, cwd was not the project dir so .claude/agents/ subagents were not recognized) -- see CLAUDE.md note.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-19 22:07:32 -04:00
toanhoangandClaude Sonnet 5 77dbd15450 feat: add Inventory Scans review page
List + inline edit (model within category, area, assignee, specs, asset tag, serial number) + delete. No create form -- creation only happens via the bot.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-19 22:07:25 -04:00
toanhoangandClaude Sonnet 5 ae84737620 feat: add session-authed Area + InventoryScan API routes
GET /api/areas, GET /api/inventory-scans, PATCH+DELETE /api/inventory-scans/[id], GET /api/inventory-scans/[id]/photo (proxies Telegram file_id on demand, derives Content-Type from file_path extension since Telegram serves application/octet-stream regardless of actual type).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-19 22:07:18 -04:00
toanhoangandClaude Sonnet 5 17c5882e86 feat: add bot-facing API routes (catalog, areas, employees, asset-models, inventory-scans)
Auth via BOT_INGEST_SECRET bearer header (src/lib/bot-auth.ts), not Auth.js session -- caller is the inventory-capture Telegram bot service, not a browser.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-19 22:07:10 -04:00
toanhoangandClaude Sonnet 5 d5a0f1cb74 feat: add bot-secret auth helper for /api/bot/* routes
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-19 22:07:07 -04:00
toanhoangandClaude Sonnet 5 8a02a7c7ef feat: add InventoryScan validation + CRUD (src/lib/inventory-scans.ts)
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-19 22:07:00 -04:00
toanhoangandClaude Sonnet 5 1e6dec9cf2 feat: add Area validation + CRUD (src/lib/areas.ts)
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-19 22:06:58 -04:00
toanhoangandClaude Sonnet 5 3078d9e8c5 feat: add Area and InventoryScan Prisma models + migrations
Office-move inventory kiem ke feature. See docs/asset-license-management.md InventoryScan section for full business rationale (Area vs Seat, why not AssetItem, asset tag/serial nullable).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-19 22:06:52 -04:00
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
98 changed files with 13929 additions and 204 deletions
+57
View File
@@ -0,0 +1,57 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
db:
image: postgres:16-alpine
env:
POSTGRES_USER: asset_license_mgmt
POSTGRES_PASSWORD: change-me-local-dev
POSTGRES_DB: asset_license_mgmt
# No `ports:` publish to the host -- reached via the `db` hostname
# over homelab_proxy instead (see DATABASE_URL comment below), and
# skipping the host publish avoids a repeat of the "port already
# allocated" failure an earlier orphaned run's leftover container
# caused.
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
# Hostname is the service key ("db"), NOT localhost -- the self-hosted
# runner is pinned to the homelab_proxy Docker network (so it can
# resolve the `gitea` host for checkout), which means job and service
# containers are separate network members rather than sharing a
# network namespace the way GitHub-hosted runners' service containers
# do. localhost inside the job container does not reach the service.
DATABASE_URL: postgres://asset_license_mgmt:change-me-local-dev@db:5432/asset_license_mgmt
AUTH_SECRET: ci-only-dummy-secret-not-used-in-prod
AUTH_TRUST_HOST: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx prisma generate
- run: npx tsc --noEmit
- run: npm run lint
- run: npm test
- run: npm run build
+49 -39
View File
@@ -80,48 +80,58 @@ Với mỗi mục tiêu nghiệp vụ stakeholder đưa ra (vd "thêm tracking s
## Trạng thái hiện tại ## Trạng thái hiện tại
- **Task 1 xong**: đã bootstrap Next.js (TS, App Router) project thật, entity `Request` (chỉ state `Draft`) với `POST/GET /api/requests`. Backend CRUD xong cho toàn bộ entity chốt tới nay: `Request` (chỉ state `Draft`), `User`+Auth, `Employee`, `AssetItem`, `License`, `Vendor`, `PurchaseOrder`, `AssetCategory`, `AssetCategoryAttributeDef`, `AssetModel`, `Assignment`, `MaintenanceRecord`. Chi tiết field/migration từng entity xem `prisma/schema.prisma` + git log — không lặp lại ở đây, chỉ ghi những thứ **không tự derive được từ code** (quyết định, gotcha, rủi ro còn treo).
- **Migrate SQLite → Postgres xong**: CONFIRMED pass qua test-runner độc lập.
- **ESLint setup xong**: CONFIRMED pass sạch qua test-runner độc lập.
- **Retrofit `Request` sang Prisma + Zod xong (2026-07-30)**: `prisma/schema.prisma` (model `Request`, enum `RequestType`), `src/lib/prisma.ts` (singleton client), `src/lib/requests.ts` (Zod schema thay hàm validate tay, Prisma Client thay raw SQL), `scripts/migrate.ts` (gọi `prisma migrate deploy`), `tests/setup.ts`/`tests/requests.test.ts` (schema áp qua `prisma migrate deploy`, dọn dữ liệu qua `prisma.request.deleteMany()`). `src/lib/db.ts` (raw pg Pool cũ) đã xoá. **CONFIRMED qua test-runner độc lập**: 6/6 test pass trên Postgres thật, type-check sạch, lint sạch. Migration SQL đối chiếu khớp 1:1 với schema cũ (chỉ khác `type` từ `TEXT+CHECK` sang Postgres enum — tương đương semantics).
- `npm audit`: 1 "critical" nằm ở `vitest` (devDependency, CVE cần `vitest --ui` đang chạy — project không dùng UI mode nên không khai thác được trong cách dùng hiện tại). Rủi ro thực tế thấp, chưa cần fix gấp — xem "Việc còn dở".
- **Tailwind + shadcn/ui setup xong (2026-07-30, ban đầu trên v3)**: `postcss.config.js`, `src/app/globals.css`, `components.json`, `src/lib/utils.ts` (`cn()`), `src/components/ui/button.tsx` (component mẫu để xác nhận pipeline chạy được). Có 1 bug phát hiện qua verify độc lập (màu token bị bọc `hsl()` chồng lên giá trị `oklch()` sẵn có → CSS không hợp lệ, silent fail lúc render dù build/lint/tsc đều xanh) — đã fix và CONFIRMED qua verify độc lập lần 2.
- **Nâng cấp lên Tailwind v4 + shadcn CLI `@latest` xong (2026-07-30)**: bỏ `tailwind.config.ts` (theme giờ khai trong `globals.css` qua `@theme inline`), đổi `postcss.config.js` sang `@tailwindcss/postcss`, đổi dependency (`radix-ui` thay `@radix-ui/react-slot`, `tw-animate-css` thay `tailwindcss-animate`, thêm `shadcn` làm runtime dependency vì `globals.css` import `shadcn/tailwind.css`), `button.tsx` viết lại theo registry `new-york-v4` hiện tại. **CONFIRMED qua test-runner độc lập**: install/build/tsc/lint sạch, 6/6 test Request vẫn pass (không liên quan tới UI), CSS đã compile xác nhận màu sạch (không còn kiểu bug `hsl(oklch(...))`), không còn gói v3 sót lại.
- **Rủi ro tồn đọng (dormant, chưa gãy)**: `button.tsx` mới bỏ `React.forwardRef` (convention của registry hiện tại là cho React 19), nhưng project đang pin **React 18.3.1** — nếu sau này có chỗ truyền `ref` vào `<Button>`, ref sẽ không được forward đúng cách. Hiện tại không chỗ nào trong code truyền `ref` nên chưa phát sinh lỗi — nhưng cần nhớ khi build UI thật, tránh `<Button ref={...}>` hoặc phải tự thêm lại `forwardRef` nếu cần.
- **Git repo khởi tạo + push lên GitHub xong (2026-07-30)**: `https://github.com/toandev96/ubi-dan-itam.git` (private), remote `origin`/branch `master`. Business context (`docs/asset-license-management.md`) đã move từ `ubisoft/processes/` vào trong repo này cùng ngày, để travel theo code khi clone ở máy khác (vd VM Linux ở nhà qua Proxmox) — không còn phụ thuộc thư mục `processes/` ở workspace ngoài repo nữa cho riêng phần business context của app này.
- **Auth (login nội bộ, không phải SSO công ty) xong (2026-07-31)**: `User` Prisma model (`prisma/schema.prisma`), `src/lib/users.ts` (Zod + `createUser()` + `verifyUserCredentials()`, bcryptjs hash), `scripts/create-user.ts` (tạo account bằng CLI, không có self-registration), Auth.js v5 (`next-auth@^5.0.0-beta.32`, Credentials provider, JWT session, không dùng `@auth/prisma-adapter` vì không cần OAuth/DB session) ở `src/auth.ts` + `src/app/api/auth/[...nextauth]/route.ts`, trang login `src/app/login/page.tsx`. Permission model **flat** (bất kỳ User nào login cũng làm mọi thứ được — quyết định đã chốt, không có `role`). **CONFIRMED qua verify độc lập** (test-runner + security review): 16/16 test pass (6 cũ + 10 mới) trên Postgres thật, tsc/lint/build sạch, không lộ password, bcrypt compare đúng chuẩn constant-time, `AUTH_SECRET` fail-closed thật khi thiếu (đã test thực tế, không fallback giá trị đoán được).
- **Finding nhỏ (chưa block, cần fix sau)**: timing side-channel trong `verifyUserCredentials` — trả về sớm khi email không tồn tại (bỏ qua bcrypt compare), nên request với email sai vs password sai có thời gian phản hồi khác nhau chút, có thể dò được email nào tồn tại. Fix: luôn chạy `bcrypt.compare()` kể cả khi không tìm thấy user (so với 1 hash giả cố định).
- **Finding nhỏ (chấp nhận được, cần track)**: `scripts/create-user.ts --password <giá trị>` lộ password vào shell history/process list — chấp nhận vì chỉ IT admin tự chạy tay, không phải self-registration công khai, nhưng nên cải thiện sau (đọc từ prompt/stdin thay vì argv).
- **Note triển khai**: chạy `next start` (production) cần thêm `AUTH_TRUST_HOST=true` hoặc `AUTH_URL` trong env, nếu không sẽ gặp lỗi `UntrustedHost` — đã thêm vào `.env.example`/`.env` khi deploy thật (xem bullet deploy bên dưới).
- **Route-gating xong (2026-07-31)**: `src/middleware.ts` (protect mọi page route theo default-deny, redirect `/login` nếu chưa đăng nhập, trừ `/login` chính nó — tránh vòng lặp redirect), `src/auth.config.ts` (config Auth.js edge-safe, tách ra khỏi `src/auth.ts` — **không** có Credentials provider vì `authorize()` gọi `verifyUserCredentials()` kéo theo `bcryptjs`, là Node API không chạy được trên Edge Runtime mà `middleware.ts` dùng; `src/middleware.ts` tự tạo instance `NextAuth(authConfig)` riêng, nhẹ, chỉ để decode JWT session cookie — xác nhận qua build thật: trước khi tách, `next build` báo warning bcryptjs/jose bundle vào Edge Runtime (middleware 115kB); sau khi tách, build sạch, middleware còn 78.9kB). `src/auth.ts` giờ spread `authConfig` + thêm Credentials provider thật, dùng cho API route/Server Component (chạy Node runtime).
- `config.matcher` của middleware loại trừ toàn bộ `/api` (không chỉ `/api/auth`) — API route tự check `auth()` lấy session ngay trong handler (không dựa hoàn toàn vào middleware, theo đúng khuyến nghị chính thức của Auth.js: verify session càng gần chỗ fetch data càng tốt). `/api/requests` (`GET`/`POST`) đã thêm check `auth()`, trả `401 {"error":"Unauthorized"}` nếu chưa login — xem `src/app/api/requests/route.ts`.
- `/` (home page placeholder) **có** yêu cầu login — chọn theo nguyên tắc "mặc định chặn hết, allowlist rõ ràng cho cái không cần" thay vì opt-in dễ quên route mới.
- Test: `tests/requests.test.ts` mock `@/auth` (`vi.mock`) thay vì gọi `auth()` thật, vì `auth()` không tham số đọc cookie qua `next/headers` — chỉ hoạt động khi chạy trong request-handling thật của Next.js, không phải khi test import gọi thẳng `GET()`/`POST()` (bypass hoàn toàn Next). Test mặc định mock session hợp lệ (`beforeEach`) để không phá test nghiệp vụ cũ, có riêng 2 test set mock về `null` để xác nhận 401. `middleware.ts` bản thân **không** có test tự động (chạy Edge Runtime, không test được qua vitest theo cách hiện tại) — đã verify tay qua `next build` + `next start` thật (curl xác nhận: `GET /` → 307 → `/login`, `GET /login` → 200 không loop, `GET/POST /api/requests` chưa login → 401 JSON, `GET /api/auth/session` → 200 không bị chặn). **CONFIRMED**: `npx tsc --noEmit`, `npm run lint`, `npm test` (18/18, gồm 2 test 401 mới), `npm run build` đều sạch.
- **`Employee` entity xong + `Request.employee_id` retrofit sang FK thật (2026-07-31)**: `Employee` Prisma model (`prisma/schema.prisma` — `name`, `department` (string đơn giản, chưa tách entity riêng), `managerId` self-relation nullable), `src/lib/employees.ts` (Zod + CRUD, theo đúng pattern chuẩn ở "Coding conventions"), `src/app/api/employees/route.ts` (`POST`/`GET`, gate `auth()` như `/api/requests`), `tests/employees.test.ts` (11 test, Postgres thật). `Request.employeeId` — trước đây là `String` tự do, không có ràng buộc — giờ là FK thật tới `Employee` (`@relation`, migration `20260731075828_relate_request_to_employee`). **Đây là thay đổi hành vi có chủ ý tới API `Request` đã ship trước đó**: `employee_id` trong body `POST /api/requests` giờ **bắt buộc là UUID hợp lệ** (Zod `.uuid()`, không còn nhận string tuỳ ý như `"emp-1"`), và phải **trỏ tới một `Employee` có thật** — nếu không, `createRequest()` bắt lỗi FK violation của Prisma (`P2003`) và trả `400 {"error": "employee_id does not reference an existing employee"}` thay vì lỗi 500 thô. `tests/requests.test.ts` đã cập nhật: mọi test tạo `Request` giờ tạo `Employee` thật trước qua helper `createTestEmployee()` rồi dùng `.id` thật, có thêm 2 test mới cho đúng 2 case bị FK chặn (UUID sai format, UUID đúng format nhưng không tồn tại).
- **Bug hạ tầng test phát hiện + fix trong lượt này**: `tests/setup.ts` tạo DB Postgres tạm riêng cho mỗi file test bằng cách ghi đè `process.env.DATABASE_URL` trong `beforeAll` — cách này **không an toàn** khi Vitest chạy nhiều file test song song (mặc định): timing giữa `beforeAll`/query của 2 file có thể chồng nhau khiến cả 2 cùng thao tác nhầm lên 1 DB tạm. Trước đây không lộ ra vì chưa có 2 file test nào cùng đụng bảng có FK với nhau; lộ ra ngay khi `Request` có FK thật tới `Employee` (`tests/employees.test.ts` và `tests/requests.test.ts` xoá/đọc nhầm dữ liệu của nhau, lặp lại 100% mỗi lần chạy). Fix: `vitest.config.ts` thêm `fileParallelism: false` (chạy tuần tự từng file) — **CONFIRMED**: 31/31 test pass ổn định qua `npm test` (11 employees + 10 requests + 10 auth), `npx tsc --noEmit`/`npm run lint`/`npm run build` đều sạch.
- **`AssetItem` entity xong (2026-07-31)**: `AssetItem` Prisma model + enum `AssetItemStatus` (`in_stock`/`assigned`/`repair`/`retired`, default `in_stock`) — `serialNumber` (unique), `model`, `purchaseDate`, `warrantyEndDate` (nullable — không phải asset nào cũng biết hạn bảo hành lúc nhập), migration `20260731090347_add_asset_item`. `src/lib/assets.ts` (Zod + CRUD, theo đúng pattern chuẩn ở "Coding conventions", bắt lỗi Prisma `P2002` khi `serialNumber` trùng và trả `400 {"error": "serialNumber already exists"}` thay vì lỗi 500 thô), `src/app/api/assets/route.ts` (`POST`/`GET`, gate `auth()` như `/api/employees`), `tests/assets.test.ts` (11 test, Postgres thật). Asset mới luôn tạo ở status `in_stock` (không nhận status từ client lúc tạo, như `Request` luôn tạo `Draft`) — các state khác (assigned/repair/retired) sẽ set qua thao tác sau, chưa có endpoint riêng. Chưa có quan hệ tới `Vendor`/`Assignment` (2 entity đó chưa build). **CONFIRMED qua test-runner độc lập**: 42/42 test pass (11 assets + 11 employees + 10 requests + 10 auth) trên Postgres thật, `npx tsc --noEmit`/`npm run lint`/`npm run build` đều sạch.
- **Deploy lên homelab xong (2026-08-02)**: `Dockerfile` (single-stage `node:20-alpine`, `npm ci` + `npm run build` + `npm start`) và service `app` mới trong `docker-compose.yml` (port `3002:3000`, cùng network `asset-license-mgmt` với `db` để resolve hostname `db`). **Gotcha đã gặp + fix**: `node:20-alpine` không có `libssl` sẵn → Prisma không detect được OpenSSL lúc `npm ci` (postinstall chạy `prisma generate`), tự chọn nhầm engine binary (`openssl-1.1.x`) — build vẫn xanh (route API là dynamic, không chạm Prisma lúc build) nhưng **crash lúc runtime** khi query thật. Fix: `RUN apk add --no-cache openssl` trong `Dockerfile` **trước** `npm ci`. Đã verify bằng cách gọi `prisma.employee.count()` thật bên trong container sau khi rebuild — pass. Expose ra internet qua `ubi.toanhoang.work` theo đúng flow `add-service` skill ở `/opt/homelab/CLAUDE.md`: subdomain qua `cloudflare-ddns-ubi`, NPM proxy host (`10.8.2.3:3002`, SSL forced) + Let's Encrypt, Unbound Host Override cho LAN. `AUTH_TRUST_HOST=true` thêm vào `.env`/`.env.example` (đóng TODO cũ). **Chưa có user nào trong DB** — chạy `docker compose exec app npm run user:create` để tạo account đầu tiên trước khi ai có thể login thật.
- **`License` entity xong (2026-08-02)**: `License` Prisma model + enum `LicenseType` (`perpetual`/`subscription`) — `name`, `vendor` (string đơn giản, chưa tách entity `Vendor` riêng, theo đúng precedent `Employee.department`), `seatsTotal` (bắt buộc `> 0`), `seatsUsed` (default `0`, client không set được lúc tạo — giống `AssetItem.status` luôn tạo `in_stock`), `expiryDate` (nullable — perpetual không hết hạn), migration `20260802123955_add_license`. `src/lib/licenses.ts` (Zod + CRUD), `src/app/api/licenses/route.ts` (`POST`/`GET`, gate `auth()` như các entity khác), `tests/licenses.test.ts` (27 test, cả lib-level lẫn route-level, có thêm test malformed JSON/non-object body — đóng luôn 1 phần gap coverage cũ, nhưng chỉ cho `licenses`, chưa retrofit lại cho `assets`/`employees`/`requests`). **CONFIRMED qua test-runner độc lập**: 69/69 test pass, tsc/lint sạch. **Review qua `code-reviewer` (trigger: seat-count logic)**: không có bug chặn, nhưng có finding cần nhớ — `createLicense()` trả `Promise<LicenseRecord>` trần (không theo pattern `{ok,value}|{ok,error}` như `createAssetItem()`); vô hại bây giờ vì `License` chưa có unique constraint nào, nhưng nếu sau này thêm `@@unique` (vd trên `name`+`vendor`) mà không đồng thời thêm xử lý lỗi `P2002` ở `createLicense()`, request trùng sẽ trả 500 thô thay vì `400` sạch như các entity khác. Chưa fix vì hiện tại xử lý lỗi cho constraint không tồn tại là dư thừa — cần nhớ làm cùng lúc nếu/khi thêm unique constraint. - Nền tảng (2026-07-30/31): bootstrap Next.js + `Request` (Draft), migrate SQLite→Postgres, ESLint, Tailwind v4 + shadcn `@latest`, Auth.js v5 (Credentials + bcryptjs, flat permission — không role), route-gating qua `src/middleware.ts` (default-deny; `src/auth.config.ts` tách riêng vì Credentials provider dùng bcryptjs không chạy được Edge Runtime; API route tự check `auth()` vì matcher loại trừ `/api`). Deploy lên homelab (2026-08-02) tại `ubi.toanhoang.work` — gotcha `apk add openssl` đã ghi ở `/opt/homelab/CLAUDE.md`, không lặp lại ở đây.
- `Request.employeeId` retrofit string→FK (2026-07-31): thay đổi hành vi API có chủ ý — `employee_id` giờ bắt buộc UUID hợp lệ + phải trỏ Employee có thật, trả `400` sạch thay vì 500 nếu không (P2003).
- `License.vendor` retrofit string→`vendorId` FK (2026-08-02), cùng lúc `PurchaseOrder` (gộp `Receipt`) ra đời với `purchaseOrderId` (nullable FK) ở cả `AssetItem`/`License`/`Request` (3 chiều traceability khác nhau — xem `docs/asset-license-management.md`).
- Phân cấp tài sản (2026-08-03): `AssetCategory` (`valueGroup` high/low_value) → `AssetCategoryAttributeDef` (thuộc tính động theo category, unique `(categoryId,key)`) → `AssetModel` (`specs` JSONB, validate động qua Zod build runtime từ AttributeDef). `AssetItem.model` (string) retrofit sang `AssetItem.modelId` (FK).
- `Assignment` xong + code-reviewer gate PASS (2026-08-03): CRUD chuẩn + `transferAssignment()` (đóng Assignment cũ + tạo mới cùng 1 transaction, cố tình **không** đụng `AssetItem.status`/`License.seatsUsed` vì resource không thực sự rảnh giữa chừng khi transfer) — fix 1 gap HIGH code-reviewer phát hiện (trước đó phải gọi 2 request rời, có race window + không rollback). `PATCH /api/assignments/[id]` dispatch theo body: có `employeeId` → transfer, có `returnedAt` → return, cả hai → 400. CONFIRMED qua test-runner độc lập: 284/284 test, tsc/lint sạch.
- UI cơ bản đã có (nhánh `worktree-ui-basic`, đã merge vào `feat/assignment`, đã push): shared nav + fetch hook + table/select/label/textarea primitives, list+create cho mọi entity, list+create+return cho `Assignment`. Chưa có UI cho transfer (chỉ có API).
- UI cho `MaintenanceRecord` xong (2026-08-04, `src/app/maintenance-records/page.tsx`): list+create+close, mirror per-row close mini-form pattern của `Assignment`'s return UI; `targetStatus` select tái dùng closed `AssetItemStatus` set client-side (cùng precedent với licenses page's `licenseType` select). tsc/lint sạch, 334/334 test.
- UI transfer cho `Assignment` xong (2026-08-04, `src/app/assignments/page.tsx`): per-row employee `<Select>` + nút Transfer cạnh nút Return sẵn có (chỉ hiện ở row active — `returned_at` null), PATCH `{employeeId}` — route đã tự dispatch sang `transferAssignment()`. Không có thay đổi lib/API, tsc/lint sạch.
- `MaintenanceRecord` xong + code-reviewer gate PASS (2026-08-04): entity mới track sửa chữa/bảo hành cho `AssetItem` (spec BA+stakeholder, xem `docs/asset-license-management.md`) — `type` (enum `repair`/`warranty`/`preventive`), `vendorId` (tái dùng `Vendor`, nullable), không track `cost`. Mutator đầu tiên ghi `AssetItem.status` ngoài `Assignment`: mở record → auto `status=repair` (chặn 400 nếu `AssetItem` đang `retired` hoặc có `Assignment` active hoặc đã có record khác đang mở); đóng record (`PATCH`, set `endDate`+`resolutionNotes`) nhận `targetStatus` bắt buộc, giới hạn chỉ `in_stock`/`retired` (không cho `assigned`/`repair` — tránh tạo `AssetItem.status=assigned` mồ côi không có `Assignment` tương ứng, fix sau code-reviewer MEDIUM finding). Code-reviewer tìm 1 gap HIGH (guard `updateMany` ban đầu dùng `status: {not:"retired"}` thay vì pin đúng `status:"in_stock"` như `Assignment`, cho phép race mở 2 record cùng lúc / đụng độ với `Assignment`) — đã fix, giờ mirror đúng pattern atomic guard của `createAssignment()`. CONFIRMED qua test-runner độc lập: 327/327 test (chạy lại 3 lần liên tiếp), tsc/lint sạch.
- Test-infra: fix gốc rễ race condition (2026-08-04) — bug lộ ra thật khi lên tới file test thứ 12 (`tests/maintenance-records.test.ts`), gây `PrismaClientKnownRequestError: Foreign key constraint violated` không ổn định ở `beforeEach` của 2 file khác dù chạy riêng lẻ vẫn xanh (root cause: `process.env.DATABASE_URL` bị nhiều file test ghi đè lên cùng 1 biến toàn cục, PrismaClient của `src/lib/prisma.ts` singleton rebind sai DB khi file này chạy đè lên timing của file khác). Fix thật: `tests/setup.ts` giờ dùng `vi.mock("@/lib/prisma", async () => {...})` — mỗi file test tự tạo DB tạm riêng + 1 `PrismaClient` riêng (qua option `datasourceUrl`, không đụng `process.env` toàn cục nữa) — không còn state nào chia sẻ giữa các file. `fileParallelism: false` đã bỏ khỏi `vitest.config.ts` (về default `true`).
- **`InventoryScan` + `Area` (2026-08-20, xem `docs/asset-license-management.md` mục "InventoryScan — kiểm kê văn phòng move" cho đầy đủ business context/quyết định):** entity mới phục vụ kiểm kê văn phòng move, nhập liệu qua bot Telegram (`/api/bot/*`, auth riêng qua `src/lib/bot-auth.ts` — bearer secret `BOT_INGEST_SECRET`, không phải session), review qua trang `/inventory-scans` (session-authed bình thường). Files mới: `src/lib/areas.ts`, `src/lib/inventory-scans.ts`, `src/lib/bot-auth.ts`, `src/app/api/bot/**`, `src/app/api/inventory-scans/**`, `src/app/api/areas/**`, `src/app/inventory-scans/page.tsx`, `tests/areas.test.ts`, `tests/inventory-scans.test.ts`, `tests/bot-api.test.ts`. **Lưu ý quan trọng: build KHÔNG qua pipeline `ba`→`pm`→`feature-implementer`→`test-runner`→`code-reviewer` chuẩn của repo này** — làm qua 1 phiên Claude Code thao tác từ xa qua SSH (không mở với cwd = thư mục project nên không nhận diện được `.claude/agents/`), tự viết test theo đúng convention (`tests/setup.ts` pattern, Postgres thật) và tự chạy `npm test`/`tsc --noEmit`/`npm run lint` — **369/369 test pass (35 test mới), tsc sạch, lint sạch** — nhưng **chưa qua `code-reviewer` gate thật**, khác với `Assignment`/`MaintenanceRecord` ở trên. Nên cân nhắc chạy `code-reviewer` cho diff này khi có phiên Claude Code mở đúng cwd. 3 migration (`20260820150000_add_area_and_inventory_scan`, `20260820180000_add_asset_tag`, `20260820190000_add_serial_number`) viết tay thay vì qua `prisma migrate dev --name ...` như convention chuẩn — đã áp dụng + test xanh nên không cần làm lại, nhưng lệch tiền lệ, ghi rõ ở đây để không ai nhầm là đã theo đúng quy trình.
- **`ExpectedAsset` import + bot "🔍 Tìm trong list" (2026-08-21):** sau khi quyết định tắt OCR (xem `Ollama`/`ITAM - Inventory Scans` note trong vault Obsidian — chỉ ~300-400 asset, không đáng đánh đổi tốc độ/độ chính xác), thêm tính năng upload trước 1 file CSV asset đã biết (asset tag/model/serial/khu vực & người giữ cũ) qua trang `/inventory-scans/import` (session-auth), để lúc đi kiểm kê bot có nút "🔍 Tìm trong list" thay vì bắt gõ tay từ đầu — chọn xong tự gợi ý luôn nút "Giữ nguyên: <khu vực/người giữ cũ>" ở bước Area/Assignee. Model mới `ExpectedAsset` (migration viết tay `20260821194252_add_expected_asset`, cùng kiểu deviation như 3 migration InventoryScan ở trên). Files mới: `src/lib/expected-assets.ts` (parser CSV tự viết tay, không thêm dependency — chấp nhận vì file nhỏ/định dạng đơn giản), `src/app/api/expected-assets/import/route.ts`, `src/app/api/bot/expected-assets/route.ts`, `src/app/inventory-scans/import/page.tsx`; sửa `src/lib/inventory-scans.ts` (`createInventoryScan` nhận thêm `expectedAssetId`, đánh dấu matched trong cùng transaction) + `src/app/api/bot/inventory-scans/route.ts`. **Chưa viết Vitest test cho phần này** — chỉ test tay end-to-end qua simulate webhook calls trực tiếp (curl POST vào `/webhook` với payload Telegram giả) + `tsc --noEmit`/`npm run lint` sạch, KHÔNG qua `code-reviewer` gate và KHÔNG có test tự động — lệch chuẩn nhiều hơn cả `InventoryScan` gốc, cần bổ sung test + review khi có phiên đúng cwd.
- **`AssetModel.warrantyMonths` -> auto-fill `AssetItem.warrantyEndDate` (2026-08-21):** thêm field `warrantyMonths` (nullable) vào `AssetModel` — thời hạn bảo hành chuẩn theo dòng máy/hãng. `createAssetItem()` trong `src/lib/assets.ts` tự tính `warrantyEndDate = purchaseDate + warrantyMonths` (helper `addMonthsClamped()`, clamp đúng cuối tháng — vd 31/12 + 2 tháng = 28/2, không tràn sang tháng 3) CHỈ khi caller không tự truyền `warrantyEndDate` — vẫn override được per-item (vd batch mua có bảo hành mở rộng khác model chuẩn). UI `/asset-models` có thêm input "Standard Warranty (months)"; trang `/assets` không cần sửa gì (đã omit `warrantyEndDate` khỏi payload khi để trống, server tự điền). Migration viết tay `20260821200217_add_warranty_months`, cùng kiểu deviation như các migration khác trong session này. Test: smoke-test tay qua tsx script (không phải Vitest) — model 36 tháng, case thường, case clamp cuối tháng, case override tay đều đúng, đã dọn data test. `tsc --noEmit`/`lint` sạch.
- **Asset Item CSV import/export (2026-08-23):** trang `/assets` có thêm nút "Export CSV" và "Import CSV". Export (`GET /api/assets/export`) render toàn bộ `AssetItem` ra CSV (cột `manufacturer`/`model` thay vì `modelId` thô để đọc/sửa tay được). Import (`POST /api/assets/import`) parse CSV rồi tra `manufacturer`+`model` (không phân biệt hoa/thường) ra đúng `AssetModel` đã có sẵn — KHÔNG tự tạo model mới, phải tạo trước ở `/asset-models`. Lỗi từng dòng (model không khớp, thiếu field bắt buộc, ngày sai định dạng...) được báo riêng, không chặn các dòng hợp lệ khác — trả về `{created, errors: [{row, serialNumber, error}]}`. Warranty vẫn tự tính như bình thường (từ `AssetModel.warrantyMonths`) khi CSV không có cột `warranty end date`. Logic parse CSV dùng chung với `ExpectedAsset` (tách ra `src/lib/csv.ts`: `splitCsvLine`/`splitCsvLines`/`normalizeHeader`/`csvField`). Files mới: `src/lib/csv.ts`, `src/app/api/assets/import/route.ts`, `src/app/api/assets/export/route.ts`; sửa `src/lib/assets.ts` (`parseAssetItemsCsv`/`importAssetItems`/`exportAssetItemsCsv`), `src/lib/expected-assets.ts` (dùng `csv.ts` chung thay vì code trùng), `src/app/assets/page.tsx`. Test: smoke-test tay qua tsx script (export ra CSV hợp lệ, import CSV có cả dòng hợp lệ + dòng lỗi model + dòng thiếu ngày mua — đúng cả 3 case, đã dọn data test), **chưa có Vitest, chưa qua code-reviewer** — cùng deviation như các feature khác trong branch này. `tsc --noEmit`/`lint` sạch.
- **`AssetItem.assetTag` + status-aware import + `purchaseDate` optional (2026-08-24):** để bulk-import 1 file export ITAM thật của công ty (ServiceNow-style, 613 asset thật, sheet "Page 1" = union của 8 sheet còn lại trong file gốc). Thay đổi schema:
- `AssetItem.assetTag` (nullable, unique) — mã tag công ty (vd "DANLAP001"), trước đây chỉ `InventoryScan` có field này (dành riêng cho move), giờ có ở cả bảng chính.
- `AssetItem.purchaseDate` đổi thành **nullable** (trước là NOT NULL) — dữ liệu thật có 6/613 asset không rõ ngày mua (thiếu "Order received" trong file gốc), quyết định 2026-08-24: import hết, không bỏ dòng nào, để trống ngày mua và tính sau. Form tạo tay `/assets` vẫn bắt buộc nhập ngày (input HTML `required`) — chỉ nới lỏng ở tầng DB/import cho dữ liệu lịch sử.
- `importAssetItems()` giờ nhận thêm cột `status` (map free-text "In use"→`assigned`, "In stock"→`in_stock`, "Repair"/"Retired"...) — khác với tạo tay qua UI (luôn mặc định `in_stock` vì đó là "vừa mua mới", không đúng khi import tài sản ĐÃ deploy thật). `createAssetItem()` nhận thêm `status?` param, chỉ `importAssetItems()` dùng.
- Migration viết tay: `20260824081044_add_asset_item_asset_tag`, `20260824082411_asset_item_purchase_date_nullable`.
**Kết quả import thật:** 613/613 tạo thành công, 0 lỗi (khớp 100% với file gốc: 542 assigned + 71 in_stock). Trước khi import phải seed thủ công 14 `AssetCategory` mới + 135 `AssetModel` mới (script `seed-models.ts`, tự viết, chạy 1 lần qua tsx rồi xoá — không phải feature UI, vì import `AssetItem` cố tình KHÔNG tự tạo model, xem comment `importAssetItems()`). 6 asset thiếu ngày mua: `DANCONT014, DANCONT017, DANCONT018, DANCONT020, DANCONT021, DANCONT023` — cần bổ sung tay sau. **Phạm vi đợt này CHƯA làm:** Cost/Cost currency, PO number (text tự do, không map được vào `purchaseOrderId` UUID hiện tại), gán nhân viên thật (`User Assigned` → cần entity `Assignment` riêng, chưa đụng tới) — để đợt sau.
- **Backfill Employee + Assignment từ dữ liệu thật (2026-08-24):** tiếp theo đợt import 613 AssetItem — trích 95 Employee khác nhau (từ cột "User Assigned", 2 người không có Department trong file gốc do là "(Other)"/"(Freelance)" → gán tạm "Unknown") + 384 Assignment (asset ↔ nhân viên, dùng ngày "Assigned Date" thật của file làm `assignedAt` lịch sử). **Quan trọng: KHÔNG dùng `createAssignment()` có sẵn** — hàm đó cho luồng nghiệp vụ SỐNG (cấp phát mới từ kho, bắt buộc AssetItem đang `in_stock`, tự chuyển sang `assigned`, không cho set `assignedAt` tay). Asset import đã có sẵn `status=assigned` đúng thực tế rồi nên gọi hàm đó sẽ bị từ chối hết — đây là backfill lịch sử, phải ghi thẳng qua Prisma (`prisma.assignment.create()` trực tiếp, script `seed-assignments.ts`, chạy 1 lần qua tsx rồi xoá, không phải feature UI/lib mới). Kết quả: 95 employee + 384 assignment, 0 lỗi, khớp 100% (kiểm tra chéo DANLAP001 → Duc Luu Minh, đúng phòng ban + ngày gán). 158 asset `status=assigned` không có Assignment — đúng thực tế (thiết bị dùng chung, file gốc không gán riêng ai). **Chưa làm:** Cost, PO number, Job/Manager/Email của Employee (schema `Employee` hiện chỉ có name+department, chưa mở rộng thêm field từ file gốc).
- **`AssetItem.cost`/`costCurrency`/`externalPoNumber` + import upsert (2026-08-24):** thêm 3 field: `cost` (Decimal(14,2), không dùng Float để tránh sai số làm tròn), `costCurrency` (text tự do vd "VND"/"CNY"), `externalPoNumber` (text tự do — **khác** `purchaseOrderId` vốn là UUID FK thật vào bảng `PurchaseOrder`; PO number từ export ngoài thường là text rác không map được vào entity thật). CSV import/export cập nhật theo (`parseAssetItemsCsv`/`exportAssetItemsCsv`), cột `po number` tách biệt alias với `purchase order id` để không đụng nhau.
- **`importAssetItems()` giờ hỗ trợ upsert** (2026-08-24, thay đổi hành vi quan trọng): dòng có `assetTag`/`serialNumber` đã tồn tại sẽ **UPDATE** thay vì báo lỗi trùng như trước — cần thiết để backfill cost/PO lên 613 asset đã import trước đó, và hữu ích cho lần công ty xuất file ITAM mới sau này (re-sync định kỳ, không phải one-off). Update chỉ đụng field nào dòng CSV **có giá trị** (ô trống = không đổi, không phải xoá) — riêng `warrantyEndDate` chỉ update khi dòng có giá trị rõ ràng, KHÔNG tự tính lại từ `warrantyMonths` như nhánh CREATE (tránh ghi đè warranty đã sửa tay). Kết quả trả về thêm field `updated` bên cạnh `created`/`errors`.
- Dữ liệu rác dạng "Not available"/"Pending"/"N/A"/"None"/"-" (case-insensitive) ở cột PO number/cost/cost currency được coi là rỗng (hàm `cleanImportText()`/`parseImportCost()`), không lưu literal text rác vào DB.
Migration viết tay: `20260824090149_add_asset_item_cost_po`.
**Kết quả backfill thật:** chạy lại đúng file CSV 613 dòng (giờ có thêm 3 cột) → **0 tạo mới, 613 update, 0 lỗi** (đúng — không tạo trùng). 8/613 asset có cost thật (khớp 100% giá trị gốc, kiểm tra chéo), 2/613 có PO number thật, còn lại đúng là rác đã lọc.
**Việc còn dở, ý tưởng đã thống nhất với user (chưa làm):** tách `Department` thành entity riêng (như `Area`/`Vendor`), cho cả `Employee` và `AssetItem` cùng tham chiếu qua FK thay vì mỗi bên giữ 1 chuỗi text tự do — giúp asset dùng chung (không gán ai) vẫn gắn được department, và tránh lệch chính tả giữa 2 bảng.
- **CI (2026-08-20):** Gitea Actions, workflow `.gitea/workflows/ci.yml` — chạy `npm ci` + `npx prisma generate` + `npx tsc --noEmit` + `npm run lint` + `npm test` (Postgres service container) trên mọi push/PR. Cần runner tự host (`act_runner`) đã đăng ký với instance Gitea `git.toanhoang.work` — xem chi tiết setup ở `/opt/homelab/gitea/CLAUDE.md` (ngoài repo này) nếu cần thêm runner. **Chưa có auto-deploy** — deploy production vẫn làm tay trên `appsrv01`: `docker compose up -d --build app` rồi `docker exec asset-license-mgmt-app npm run db:migrate` (thứ tự bắt buộc: build trước, migrate sau khi container mới đã chạy).
## Việc còn dở ## Việc còn dở
- `fileParallelism: false` (fix race condition test-infra) là fix đúng nhưng "cùn" — chặn song song **toàn bộ** suite thay vì chỉ fix gốc rễ (`tests/setup.ts` ghi đè `process.env.DATABASE_URL` toàn cục thay vì truyền connection string riêng cho từng Prisma Client instance). Test suite sẽ chạy chậm dần tuyến tính khi thêm entity mới (`AssetItem`, `License`...). Cân nhắc refactor `tests/setup.ts` để mỗi file tự có Prisma Client riêng trỏ đúng DB tạm của mình, rồi bật lại parallelism — chưa gấp ở quy mô hiện tại (3 file, ~15s). - Fix timing side-channel ở `verifyUserCredentials`: trả về sớm khi email không tồn tại (bỏ qua bcrypt compare) → thời gian phản hồi khác nhau giữa "email sai" vs "password sai", có thể dò được email tồn tại. Fix: luôn chạy `bcrypt.compare()` kể cả khi không tìm thấy user (so với 1 hash giả cố định).
- Fix timing side-channel ở `verifyUserCredentials` (xem note ở "Trạng thái hiện tại"). - Cải thiện `scripts/create-user.ts` để password không lộ qua CLI arg (đọc từ stdin/prompt thay vì `--password`) — chấp nhận được vì chỉ IT admin tự chạy tay, không phải self-registration công khai.
- Cải thiện `scripts/create-user.ts` để password không lộ qua CLI arg (đọc từ stdin/prompt thay vì `--password`). - Bump `vitest` lên 4.x (fix advisory critical GHSA-5xrq-8626-4rwp — không khai thác được vì project không chạy `vitest --ui`) — không khẩn cấp.
- Bump `vitest` lên 4.x (fix advisory critical GHSA-5xrq-8626-4rwp — hiện không khai thác được vì project không chạy `vitest --ui`, nhưng nên dọn khi có breaking-change window) — không khẩn cấp. - Rà lại advisory riêng của `next` (vài high/moderate — SSRF qua WebSocket upgrade, middleware bypass) khi có dịp.
- Rà lại advisory riêng của `next` (prod dependency, có vài high/moderate — vd SSRF qua WebSocket upgrade, middleware bypass) khi có dịp, tách biệt khỏi đợt retrofit Prisma/Zod này. - Test coverage gap: `assets`/`employees`/`requests` chưa có test malformed JSON body / non-object body (`licenses`/`assignments` đã có).
- Test coverage gaps (chưa block gì, chỉ là follow-up): chưa test malformed JSON body, chưa test JSON body không phải object (array/string). - `Button` (shadcn) không `forwardRef` (registry hiện tại target React 19), project pin React 18.3.1 — dormant risk, chỉ phát sinh nếu sau này có chỗ truyền `ref` vào `<Button>`.
- `Button` (shadcn) không `forwardRef`, React đang pin 18 — dormant risk, xem note ở "Trạng thái hiện tại". - **Finding từ code-reviewer gate cho `Assignment` (2026-08-03), cả hai đều dormant/chưa gãy vì chưa có delete endpoint nào và `createAssignment()` hiện chỉ được gọi qua route đã validate:**
- **`Vendor`/`PurchaseOrder`(gộp `Receipt`)/`Assignment`/`AssetCategoryAttributeDef` (2026-08-02): spec đầy đủ ở `docs/asset-license-management.md`, đã có task list phụ thuộc-đúng do `pm`-pass sequencing (2026-08-02), sẵn sàng giao `feature-implementer` theo đúng thứ tự dưới đây (3 lane độc lập, có thể xen kẽ):** - XOR (`assetItemId`/`licenseId`) chỉ enforce ở tầng Zod (route boundary), không lặp lại bên trong `createAssignment()` — nếu sau này có caller khác gọi thẳng lib function (script, bulk-import) bỏ qua validate, có thể tạo Assignment vi phạm XOR.
- **Lane A (Vendor → License.vendorId → PurchaseOrder → purchaseOrderId x3)**: (1-4) `Vendor` entity chuẩn 4 commit (model+migration/lib/API/test), không phụ thuộc gì. (5) Retrofit `License.vendor` (string) → `License.vendorId` (FK) — **1 commit gộp** (schema+migration+lib+test), theo đúng tiền lệ retrofit `Request.employeeId` (commit `49d96b7`, không tách lớp như entity mới vì tách ra sẽ để lại state nửa vời không chạy được) — tiện thể fix luôn finding cũ của `code-reviewer`: `createLicense()` chưa theo pattern `{ok,value}|{ok,error}`, giờ **bắt buộc** phải thêm vì `vendorId` là FK mới (cần bắt `P2003`). Phụ thuộc (1). (6-9) `PurchaseOrder` entity chuẩn 4 commit (`vendorId` FK, `receivedBy` FK→`Employee`, Zod `.refine()` cho ràng buộc `receivedAt`/`receivedBy` cùng null hoặc cùng có giá trị, bắt `P2003` cho cả 2 FK, theo đúng pattern `{ok,error}` ngay từ đầu) — phụ thuộc (1) `Vendor` + `Employee` (đã có). (10) `AssetItem.purchaseOrderId` — 1 commit gộp (nullable FK). (11) `License.purchaseOrderId` — 1 commit gộp **riêng** với task (5) dù cùng file, vì trigger phụ thuộc khác nhau (`Vendor` vs `PurchaseOrder`) — gộp chung sẽ vi phạm nguyên tắc 1-commit-1-thay-đổi-nghiệp-vụ. (12) `Request.purchaseOrderId` — 1 commit gộp. (10)(11)(12) đều chỉ phụ thuộc (6), độc lập lẫn nhau, thứ tự giữa 3 task này không quan trọng. - FK `assetItemId`/`licenseId` trên `Assignment` dùng `ON DELETE SET NULL`, không nhất quán với `employeeId` (`ON DELETE RESTRICT`) — nếu sau này có endpoint xoá `AssetItem`/`License`, các `Assignment` lịch sử tham chiếu tới nó sẽ bị null hoá cả 2 field, xoá mất thông tin đã từng assign gì (và re-vi-phạm XOR retroactively). Nên đổi sang `RESTRICT` như `employeeId` khi/nếu thêm delete endpoint.
- **Lane B (AssetCategory → AssetCategoryAttributeDef + AssetModel → wire dynamic validation → AssetItem.modelId)**: **gap phát hiện khi sequencing** — `AssetCategory`/`AssetModel`/`ConsumableItem` đã có spec từ round BA trước (2026-08-02, xem "Phân loại tài sản" trong doc) nhưng **CHƯA build** (không có trong `prisma/schema.prisma` hiện tại, `AssetItem.model` vẫn là string tự do) — phải làm trước thì `AssetCategoryAttributeDef` mới có ý nghĩa. (13-16) `AssetCategory` entity chuẩn 4 commit (`name`, `valueGroup` enum `high_value`/`low_value`), không phụ thuộc gì. (17-20) `AssetCategoryAttributeDef` entity chuẩn 4 commit (`categoryId` FK, `key`, `label`, `dataType` enum, `required`, `sortOrder`, unique `(categoryId,key)` → bắt `P2002`) — phụ thuộc (13), độc lập với `AssetModel`. (21-24) `AssetModel` entity chuẩn 4 commit (`categoryId` FK + business rule "category phải `valueGroup=high_value`" — cần lookup DB, không phải Zod thuần; `manufacturer`, `name`, `specs` JSONB — ở task này `specs` chỉ validate lỏng là object hợp lệ, CHƯA khớp động theo `AssetCategoryAttributeDef`) — phụ thuộc (13), độc lập với (17-20). (25) Wire dynamic specs validation vào `AssetModel` create/update — **1 commit riêng**, không gộp vào (22), vì đây là logic mới/rủi ro (build Zod schema runtime từ `AssetCategoryAttributeDef`, map `dataType`→`z.string()/number()/boolean()/coerce.date()`, từ chối key lạ, bắt buộc key `required`) — phụ thuộc (18) và (22). (26) Retrofit `AssetItem.model` (string) → `AssetItem.modelId` (FK) — 1 commit gộp, phụ thuộc (21) (không cần đợi (25)). - (LOW, chưa cần fix) `returnedAt` không validate `>= assignedAt`; decrement `License.seatsUsed` lúc return không có floor guard (an toàn hiện tại vì luôn đi kèm 1 increment trước đó, nhưng không có defensive floor nếu state drift do can thiệp DB tay).
- **Lane C (`Assignment`, độc lập hoàn toàn với Lane A/B — chỉ cần `Employee`+`AssetItem`+`License` đã có sẵn)**: (27) model+migration (`employeeId` FK, `assetItemId`/`licenseId` nullable FK, `assignedAt`, `returnedAt` nullable, `signedAt` nullable). (28) lib validation+CRUD — rủi ro nhất trong cả 4 entity: Zod `.refine()` XOR `assetItemId`/`licenseId`, chặn >1 `Assignment` active/`AssetItem`, transaction tăng/giảm `License.seatsUsed` kèm chặn overbook (`seatsUsed >= seatsTotal`), đồng bộ `AssetItem.status` (assigned/in_stock), helper transfer (đóng cũ+mở mới cùng 1 transaction). (29) API routes — **cần quyết định shape mới**: đây là entity đầu tiên cần thao tác "update" (return/transfer) trong khi codebase hiện tại **mọi entity đều chỉ có POST(create)+GET(list), chưa có route update nào** — đề xuất mặc định `POST /api/assignments` (assign/transfer) + `PATCH /api/assignments/[id]` (return, nhận `{returnedAt}}`), cần xác nhận lại khi giao task cho `pm`/`feature-implementer` vì không có tiền lệ. (30) tests. **(31) Bắt buộc code-reviewer pass riêng cho Assignment (diff 27-30) trước khi coi là xong** — đúng rule ở "Multi-agent dev workflow" (state-mutation-với-invariant, seat-count) — không phải 1 commit, là 1 gate. - **Finding LOW từ code-reviewer gate cho `MaintenanceRecord` (2026-08-04), chưa fix (chấp nhận được, dormant):** `closeMaintenanceRecord()`'s update cuối cùng lên `AssetItem.status` là `update()` thường, không có WHERE-guard theo trạng thái hiện tại — cùng loại rủi ro dormant như `Assignment.returnedAt`/`seatsUsed` ở trên, chỉ phát sinh nếu có flow khác ghi `AssetItem.status` đồng thời lúc đóng record (hiện chưa có).
- **2 câu hỏi mở đã được stakeholder trả lời (2026-08-02, xem "Assignment" trong `docs/asset-license-management.md`) — Lane C hết bị chặn**: (a) `createAssignment()` **chặn** nếu `AssetItem.status` khác `in_stock` (400). (b) Route update dùng `PATCH /api/<entity>/[id]` (áp dụng cho cả `Assignment` và `PurchaseOrder`), không dùng action-based sub-route. - `ConsumableItem` (phần catalog, low-value) đã có spec (`docs/asset-license-management.md`) nhưng **chưa build** — sẵn sàng giao `pm` khi cần. Luồng "xin+nhận" consumable riêng (đơn giản hơn state machine `Request`) vẫn **BỊ CHẶN** chờ stakeholder trả lời 7 câu hỏi mở (xem doc).
- `ConsumableItem` (phần catalog, KHÔNG phải luồng "xin+nhận" — luồng đó vẫn BỊ CHẶN chờ 7 câu hỏi mở, xem doc): độc lập với toàn bộ 3 lane trên (chỉ cần `AssetCategory`), sẵn sàng cho `pm` chia task riêng nếu stakeholder muốn gộp vào cùng đợt — không đưa vào sequencing này vì nằm ngoài phạm vi 4 việc được giao lần này. - Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled.
- Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled - UI cho các state Request kế tiếp (hiện chỉ có Draft) — chờ spec/build state machine ở trên trước. (Lưu ý: có thể đã có tiến triển từ 1 phiên Claude Code khác chạy song song — kiểm tra `git status`/working tree trước khi bắt đầu để tránh làm trùng.)
- UI (hiện chỉ có placeholder home page) - Auth/phân quyền (ai được approve request, ai được xác nhận nhận hàng/bàn giao) — vẫn flat, chưa có role.
- Auth/phân quyền (ai được approve request, ai được xác nhận nhận hàng/bàn giao) - `InventoryScan` chưa có bước "promote" sang `AssetItem` thật (đọc serial thật, tạo `AssetItem`/`Assignment` từ 1 scan đã review) — hiện chỉ nằm ở `InventoryScan`, chưa lên sổ chính thức.
- `InventoryScan.assetTag`/`serialNumber` chưa có OCR tự động — đọc thủ công qua ảnh (nhờ Claude đọc, không phải tính năng trong app). Nếu cần tự động: cần `ANTHROPIC_API_KEY` riêng cho server + route gọi Claude Vision, stakeholder đã chọn hướng thủ công trước mắt.
- `code-reviewer` gate chưa chạy cho `InventoryScan`/`Area` (xem note ở "Trạng thái hiện tại") — nên chạy khi có phiên mở đúng cwd project.
- **Gộp `inventory-capture` vào `ubi-dan-itam` (chốt hướng 2026-08-20, chưa làm):** stakeholder quyết định nên gộp bot Telegram kiểm kê (hiện là service Python riêng `/opt/homelab/inventory-capture/`, ngoài repo này) thẳng vào app này thay vì giữ 2 service tách rời gọi nhau qua HTTP. Lý do: đúng nguyên tắc "không tách backend/frontend nếu không có lý do hạ tầng khác biệt" đã chốt ở trên; bỏ được vòng HTTP nội bộ (`/api/bot/*` + `BOT_INGEST_SECRET`) hiện dùng để 2 service tự gọi nhau; giảm số nơi giữ `BOT_TOKEN` (hiện đang lặp ở 3 chỗ: DB của `telegram-gateway`, `.env` của `inventory-capture`, `.env` của app này — xem `src/app/api/inventory-scans/[id]/photo/route.ts`). Việc cần làm: viết lại state machine hội thoại (hiện là dict Python trong `inventory-capture/app.py`) sang TypeScript, gắn vào route mới `/api/telegram/webhook` (route mà `telegram-gateway` gọi vào — khác `/api/bot/*` là route mà bot gọi vào), state hội thoại theo `chat_id` dùng in-memory `Map` là được (app này chạy `next start`, process sống lâu dài, không phải serverless — cùng rủi ro mất state khi restart như hiện tại, không tệ hơn). Nên có test theo đúng convention khi làm. Sau khi gộp, cân nhắc thêm proxy `answerCallbackQuery`/`getFile` vào `telegram-gateway` để dọn nốt `BOT_TOKEN` khỏi app này luôn (xuống còn đúng 1 nơi giữ token).
--- ---
Cập nhật lần cuối: 2026-08-02 (deploy lên homelab ubi.toanhoang.work, License entity, task sequencing cho Vendor/PurchaseOrder/Assignment/AssetCategoryAttributeDef) Cập nhật lần cuối: 2026-08-20 (`InventoryScan` xong, CI Gitea Actions setup, quyết định gộp `inventory-capture` vào app này — chưa làm)
+49 -3
View File
@@ -34,6 +34,7 @@ Theo dõi toàn bộ vòng đời tài sản/license: **Request → Approval →
| `AssetModel` | id, category (FK → `AssetCategory`, bắt buộc, phải thuộc nhóm `high_value`), `manufacturer` (vd "Dell"), `name` (vd "Latitude 5420"), `specs` (JSONB — giá trị thật của các thuộc tính động, xem `AssetCategoryAttributeDef`) — tách riêng 2 field `manufacturer`/`name`, không gộp chung 1 string, để filter/report theo hãng. Nằm giữa `AssetCategory` và `AssetItem` (1 model có nhiều `AssetItem` serial khác nhau). Xem "Phân loại tài sản" bên dưới | | `AssetModel` | id, category (FK → `AssetCategory`, bắt buộc, phải thuộc nhóm `high_value`), `manufacturer` (vd "Dell"), `name` (vd "Latitude 5420"), `specs` (JSONB — giá trị thật của các thuộc tính động, xem `AssetCategoryAttributeDef`) — tách riêng 2 field `manufacturer`/`name`, không gộp chung 1 string, để filter/report theo hãng. Nằm giữa `AssetCategory` và `AssetItem` (1 model có nhiều `AssetItem` serial khác nhau). Xem "Phân loại tài sản" bên dưới |
| `AssetCategoryAttributeDef` | id, `categoryId` (FK → `AssetCategory`), `key` (slug, vd `ram_gb`), `label` (tên hiển thị, vd "RAM (GB)"), `dataType` (enum đóng: `string`/`number`/`boolean`/`date`), `required` (bool), `sortOrder` (int) — định nghĩa thuộc tính riêng theo từng category, giá trị thật nằm ở `AssetModel.specs`. Unique `(categoryId, key)`. Xem "Phân loại tài sản" bên dưới | | `AssetCategoryAttributeDef` | id, `categoryId` (FK → `AssetCategory`), `key` (slug, vd `ram_gb`), `label` (tên hiển thị, vd "RAM (GB)"), `dataType` (enum đóng: `string`/`number`/`boolean`/`date`), `required` (bool), `sortOrder` (int) — định nghĩa thuộc tính riêng theo từng category, giá trị thật nằm ở `AssetModel.specs`. Unique `(categoryId, key)`. Xem "Phân loại tài sản" bên dưới |
| `ConsumableItem` | phụ kiện **low-value/consumable** — tên, category (bắt buộc, phải thuộc nhóm `low_value`, trỏ thẳng `AssetCategory` — **không** qua `AssetModel`, khái niệm "model" chỉ áp dụng cho asset serial hóa), số lượng tồn kho (`quantityOnHand`). **KHÔNG serial hóa, KHÔNG gắn Assignment cá nhân** — chỉ track theo số lượng. Xem "Phân loại tài sản" bên dưới | | `ConsumableItem` | phụ kiện **low-value/consumable** — tên, category (bắt buộc, phải thuộc nhóm `low_value`, trỏ thẳng `AssetCategory` — **không** qua `AssetModel`, khái niệm "model" chỉ áp dụng cho asset serial hóa), số lượng tồn kho (`quantityOnHand`). **KHÔNG serial hóa, KHÔNG gắn Assignment cá nhân** — chỉ track theo số lượng. Xem "Phân loại tài sản" bên dưới |
| `MaintenanceRecord` | **(spec BA 2026-08-04, đã chốt đủ với stakeholder — sẵn sàng cho `pm`)** khai báo sửa chữa/bảo hành cho 1 `AssetItem` cụ thể — `assetItemId` (FK, bắt buộc), `issueDescription`, `resolutionNotes` (nullable), `startDate`, `endDate` (nullable — null = đang sửa), `type` (enum: `repair`/`warranty`/`preventive`), `vendorId` (FK → `Vendor`, nullable — null = tự sửa nội bộ). Không track `cost`. Xem "MaintenanceRecord" bên dưới |
## Luồng trạng thái Request ## Luồng trạng thái Request
@@ -116,6 +117,49 @@ Entity mới, độc lập với `AssetModel`.
- Tạo `Assignment` bị chặn nếu `AssetItem.status` khác `in_stock` (400). - Tạo `Assignment` bị chặn nếu `AssetItem.status` khác `in_stock` (400).
- `License.seatsUsed` tăng/giảm đồng bộ trong 1 transaction khi assign/return; assign bị chặn nếu `seatsUsed >= seatsTotal`. - `License.seatsUsed` tăng/giảm đồng bộ trong 1 transaction khi assign/return; assign bị chặn nếu `seatsUsed >= seatsTotal`.
## MaintenanceRecord (spec BA + stakeholder, chốt 2026-08-04 — sẵn sàng cho `pm`)
Xuất phát từ yêu cầu stakeholder: "muốn thêm 1 menu maintenance — khai báo sửa chữa cho từng `AssetItem`, kể cả bảo hành." Đây là **entity mới** (không phải mở rộng field trên `AssetItem`), hiện thực hoá case đã note từ trước ở "Case đặc biệt cần xử lý" bên dưới: "Asset hỏng → gửi RMA/sửa chữa → cần state 'in repair' tách khỏi 'assigned'" — `AssetItemStatus.repair` đã có sẵn trong enum (`prisma/schema.prisma`) từ khi `AssetItem` được tạo, nhưng chưa có entity/flow nào thực sự dùng tới giá trị đó cho tới nay. `MaintenanceRecord` sẽ là mutator **đầu tiên** của `AssetItem.status` ngoài `Assignment` — `AssetItem` hiện chưa có update endpoint nào cả, nên phần đồng bộ status dưới đây cũng ngầm là spec cho phần ghi `AssetItem.status` từ `MaintenanceRecord` (không phải 1 update endpoint chung chung cho `AssetItem`).
**Field:**
- `id`, `assetItemId` (FK → `AssetItem`, bắt buộc), `type` (Postgres enum: `repair` | `warranty` | `preventive`), `vendorId` (FK → `Vendor`, nullable — null nghĩa là tự sửa nội bộ, không qua vendor ngoài), `issueDescription` (text, bắt buộc — mô tả vấn đề/lý do gửi sửa), `resolutionNotes` (text, nullable — set khi đóng record, mô tả kết quả xử lý), `startDate` (Date, bắt buộc — ngày gửi đi sửa/bắt đầu), `endDate` (Date, nullable — ngày nhận lại/hoàn tất; null = đang sửa), `createdAt`.
- Không track `cost` — theo đúng tiền lệ `PurchaseOrder` (đã chốt trước đó không track tiền, xem mục "PurchaseOrder").
- Không có field `status` riêng trên `MaintenanceRecord` — suy ra "đang sửa" (`endDate IS NULL`) vs "đã xong" (`endDate` có giá trị) từ chính `endDate`, đúng tiền lệ `PurchaseOrder.receivedAt` (không track trạng thái trùng lặp).
- Chỉ áp dụng cho `AssetItem` (hardware) — `License` không có khái niệm sửa chữa vật lý, nên **không** cần cơ chế XOR như `Assignment` (`assetItemId` là target duy nhất, luôn bắt buộc).
- `vendorId` tái dùng entity `Vendor` hiện có (nơi mua hàng) — chấp nhận gộp chung khái niệm "đối tác ngoài" cho cả mua hàng lẫn sửa chữa, không tạo bảng riêng.
- 1 `AssetItem` chỉ có tối đa 1 `MaintenanceRecord` **đang mở** (`endDate IS NULL`) tại 1 thời điểm — không thể gửi cùng lúc 2 nơi sửa. Đủ hiển nhiên về mặt vật lý (1 item vật lý không thể ở 2 nơi sửa cùng lúc) để chốt thẳng, không cần hỏi — mirror invariant "tối đa 1 `Assignment` active" đã có.
- `endDate` (nếu có) phải `>= startDate` — validate ở tầng Zod ngay từ đầu, tránh lặp lại gap đã note ở "Việc còn dở" của `../CLAUDE.md` (`Assignment.returnedAt` hiện chưa validate `>= assignedAt`).
- Không track "người tạo/ghi nhận" qua `User` — không có tiền lệ nào trong codebase hiện tại gắn business entity với `User` (chỉ dùng `User` cho login). Nếu sau này cần biết "ai gửi đi sửa", đó là field bổ sung riêng, không phải phần MVP này.
**Đồng bộ `AssetItem.status`:**
- **Mở** `MaintenanceRecord` (`POST`, chưa có `endDate`): tự động chuyển `AssetItem.status` sang `repair` trong cùng transaction (mirror cách `Assignment` đồng bộ `AssetItem.status`/`License.seatsUsed`).
- Nếu `AssetItem` đó đang có 1 `Assignment` **active** (`returnedAt IS NULL`) → **chặn tạo** `MaintenanceRecord`, trả `400` — bắt buộc phải return `Assignment` trước, giống cách `createAssignment()` hiện chặn tạo mới nếu `AssetItem.status` khác `in_stock`.
- Nếu `AssetItem` đang ở status `retired` sẵn → **chặn tạo** `MaintenanceRecord`, trả `400` (item đã thanh lý thì gửi sửa không còn ý nghĩa).
- **Đóng** `MaintenanceRecord` (set `endDate` + `resolutionNotes` qua `PATCH /api/maintenance-records/[id]`, cùng REST-shape tiền lệ đã chốt cho `Assignment`/`PurchaseOrder` — "chốt 2026-08-02 (b)" ở mục Assignment): body nhận thêm `targetStatus` bắt buộc (vd `in_stock` nếu sửa xong, `retired` nếu sửa không thành công) — **không** tự động mặc định về `in_stock`, vì sửa không phải lúc nào cũng thành công.
- Các field khác ngoài hành động "đóng" record không có update endpoint riêng — set 1 lần lúc tạo, không sửa lại sau, theo đúng pattern hiện có (vd `PurchaseOrder.orderDate` không có update endpoint).
## InventoryScan — kiểm kê văn phòng move (chốt 2026-08-20)
Bối cảnh: dự án move văn phòng cần kiểm kê toàn bộ thiết bị, càng nhỏ càng phải ghi — nhưng thao tác chụp ảnh rồi gõ Excel trên laptop quá chậm khi đi vòng quanh văn phòng. Giải pháp: bot Telegram (`Inventory_capture_bot`, chạy trên service riêng `inventory-capture` ở `/opt/homelab/inventory-capture/` trên máy `appsrv01`, KHÔNG phải trong repo này) cho phép nhập liệu bằng bấm nút trên điện thoại, gọi ngược vào app này qua các route `/api/bot/*` để ghi thẳng vào Postgres.
**Vì sao KHÔNG dùng `AssetItem` cho việc này:** `AssetItem` bắt buộc `serialNumber` (unique) + `purchaseDate` — cả hai đều không có sẵn ngay lúc đi bấm nút kiểm kê nhanh. Bắt buộc 1 trong 2 sẽ hoặc chặn luồng kiểm kê, hoặc bịa dữ liệu giả (đi ngược nguyên tắc "không dữ liệu giả" đã áp dụng xuyên suốt codebase này — xem comment `AssetItem`/`License`/`PurchaseOrder`). Quyết định: **entity mới `InventoryScan`** — bản ghi "quét lúc kiểm kê", nhẹ, không unique constraint nào chặn luồng nhập nhanh. Sau này review, cái nào cần lên sổ chính thức thì "promote" sang `AssetItem` thật (đọc serial thật lúc đó) — **bước promote này chưa build**, xem "Việc còn dở".
**Scope ban đầu (3 category, tự seed qua script, không phải migration seed):** Controller (PS5/Sony, Xbox/Microsoft), Màn hình (Dell/Monitor, HP/Monitor), Workstation (HP/Z4 G4, Lenovo/P620 — category duy nhất có `AssetCategoryAttributeDef`: `cpu`/`ram_gb`/`gpu`, cả 3 `required: false` ở tầng DB dù bot luôn hỏi đủ cả 3 trong hội thoại — lý do: field `required` này chỉ chi phối validate `AssetModel.specs`, không phải `InventoryScan.specs`, và giá trị CPU/RAM/GPU thực tế khác nhau **theo từng máy vật lý** chứ không cố định theo model, nên không nên ép cứng ở tầng `AssetModel`). Category/model KHÔNG dừng ở scope này — bot có nút "➕ Model mới" (gọi `POST /api/bot/asset-models`) cho phép thêm model mới ngay trong lúc kiểm kê, category mới thì vẫn cần seed tay (business call lớn hơn, có thể cần thêm `AssetCategoryAttributeDef`).
**`Area` (không dùng `Seat`):** Cân nhắc ban đầu là "Seat" gắn cứng với `team`, nhưng lúc build sơ đồ ghế ngồi văn phòng mới **chưa có** — đồng thời cần cả vị trí kiểu phòng (phòng họp, kho), không chỉ ghế cá nhân. Quyết định: 1 bảng `Area` chung (tên unique + `team` nullable) cho cả 2 kiểu, bot tự học dần (list nút bấm lớn theo thời gian, có nút "➕ Area mới") thay vì cần seed sơ đồ trước. Khi có sơ đồ move chính thức, chỉ cần update cột `team` cho từng `Area` đã có — không đổi cấu trúc.
**`assetTag` + `serialNumber`:** cả hai đều `String?`, **không unique/required** ở tầng `InventoryScan` (khác hẳn `AssetItem.serialNumber`) — cùng lý do "không chặn luồng kiểm kê" ở trên. Đọc từ tem dán trên thiết bị. Hiện tại nhập tay qua bot hoặc **đọc thủ công từ ảnh** (stakeholder tự gửi ảnh cho Claude, không có OCR tự động trong app) — xem "Việc còn dở" cho hướng OCR tự động nếu cần sau.
**Ảnh:** `InventoryScan.photoFileId` lưu Telegram `file_id`, KHÔNG lưu file vật lý trên disk của app này (tránh phải share volume giữa 2 service khác container/khác concern). `GET /api/inventory-scans/[id]/photo` proxy on-demand qua Telegram Bot API (`getFile` + download) mỗi lần xem — cần `BOT_TOKEN` trong `.env` của **chính app này** (không chỉ bot service) để làm việc đó. Gotcha đã gặp: Telegram trả `Content-Type: application/octet-stream` cho file download bất kể loại thật, browser hiểu nhầm thành tải file — phải tự suy `Content-Type` từ đuôi file trong `file_path` (`.jpg`/`.png`/`.webp`), không tin header gốc.
**Auth cho `/api/bot/*`:** route riêng (`src/lib/bot-auth.ts`), check header `Authorization: Bearer <BOT_INGEST_SECRET>` — **không** qua Auth.js session như mọi route khác trong app, vì caller là bot service (machine), không phải browser người dùng đã login. `BOT_INGEST_SECRET` là secret riêng, khác `AUTH_SECRET` (session JWT) và khác password của User `[email protected]` (tài khoản review dùng trang `/inventory-scans`, tạo tay qua `scripts/create-user.ts` giống mọi `User` khác — không phải service account đặc biệt).
**Trang review** (`/inventory-scans`, session-authed như mọi trang khác): list + inline edit (đổi model trong cùng category, area, người gán, specs, asset tag, serial number) + delete. Không có trang "create" riêng vì tạo mới chỉ qua bot.
Xem thêm: `src/lib/inventory-scans.ts`, `src/lib/areas.ts`, `prisma/schema.prisma` (model `Area`/`InventoryScan`), `tests/areas.test.ts`, `tests/inventory-scans.test.ts`, `tests/bot-api.test.ts`.
## Module tương lai: Rack/Slot management + IoT (chưa build, chỉ note kiến trúc) ## Module tương lai: Rack/Slot management + IoT (chưa build, chỉ note kiến trúc)
Mục tiêu xa hơn: biết chính xác asset nằm ở ô/slot nào trên tủ rack, và hướng dẫn người lấy đúng ô bằng đèn (pick-to-light). Mục tiêu xa hơn: biết chính xác asset nằm ở ô/slot nào trên tủ rack, và hướng dẫn người lấy đúng ô bằng đèn (pick-to-light).
@@ -141,7 +185,7 @@ QR gán asset vào slot (bước 1, thủ công hàng ngày) **không phải IoT
- Nhân viên nghỉ việc → checklist thu hồi asset + license (offboarding) - Nhân viên nghỉ việc → checklist thu hồi asset + license (offboarding)
- License seat-based dùng chung (vd Adobe CC theo pool chung của phòng ban) - License seat-based dùng chung (vd Adobe CC theo pool chung của phòng ban)
- Tài sản luân chuyển giữa phòng ban / chi nhánh (không phải "delivery" mới, mà là re-assign) - Tài sản luân chuyển giữa phòng ban / chi nhánh (không phải "delivery" mới, mà là re-assign)
- Asset hỏng → gửi RMA/sửa chữa → cần state "in repair" tách khỏi "assigned" - Asset hỏng → gửi RMA/sửa chữa → cần state "in repair" tách khỏi "assigned" — **giải quyết bởi `MaintenanceRecord`, spec chốt 2026-08-04, xem mục "MaintenanceRecord" bên dưới, sẵn sàng cho `pm`**
## Quyết định kiến trúc ## Quyết định kiến trúc
@@ -172,12 +216,14 @@ QR gán asset vào slot (bước 1, thủ công hàng ngày) **không phải IoT
- **`Vendor`, `PurchaseOrder` (gộp `Receipt`), `Assignment`, `AssetCategoryAttributeDef` (2026-08-02): đã có spec đầy đủ (BA), sẵn sàng cho `pm` chia task** — xem các mục "Vendor", "PurchaseOrder (gộp Receipt)", "Assignment" và "Cập nhật 2026-08-02 (3)" trong "Phân loại tài sản" ở trên. Bao gồm cả retrofit `License.vendor` (string) → `License.vendorId` (FK → `Vendor`, không cần backfill vì chưa có `License` thật trong DB) và thêm `purchaseOrderId` (nullable FK) vào `AssetItem`/`License`/`Request`. Thứ tự phụ thuộc gợi ý cho `pm`: `Vendor` → retrofit `License.vendorId` → `PurchaseOrder` (+ `purchaseOrderId` trên 3 entity) → `Assignment`; `AssetCategoryAttributeDef` độc lập, có thể làm song song. - **`Vendor`, `PurchaseOrder` (gộp `Receipt`), `Assignment`, `AssetCategoryAttributeDef` (2026-08-02): đã có spec đầy đủ (BA), sẵn sàng cho `pm` chia task** — xem các mục "Vendor", "PurchaseOrder (gộp Receipt)", "Assignment" và "Cập nhật 2026-08-02 (3)" trong "Phân loại tài sản" ở trên. Bao gồm cả retrofit `License.vendor` (string) → `License.vendorId` (FK → `Vendor`, không cần backfill vì chưa có `License` thật trong DB) và thêm `purchaseOrderId` (nullable FK) vào `AssetItem`/`License`/`Request`. Thứ tự phụ thuộc gợi ý cho `pm`: `Vendor` → retrofit `License.vendorId` → `PurchaseOrder` (+ `purchaseOrderId` trên 3 entity) → `Assignment`; `AssetCategoryAttributeDef` độc lập, có thể làm song song.
- Consumable (2026-08-02): phần phân loại (`AssetCategory`, `ConsumableItem`) đã có spec đầy đủ (BA), sẵn sàng cho `pm` chia task. Luồng "xin + nhận" (`ConsumableRequest` hoặc tương đương) **BỊ CHẶN** — cần stakeholder trả lời các câu hỏi mở: (1) có cần approval không, ai duyệt; (2) ai được tạo request xin (tự xin cho mình hay xin hộ được); (3) có giới hạn số lượng tối đa/lần xin không; (4) hết hàng thì chặn hẳn / cho "nợ" chờ nhập thêm / fulfill một phần; (5) trừ tồn kho lúc tạo request hay lúc fulfill; (6) restock tồn kho qua luồng nào, ai được làm; (7) có cần ghi nhận người phát hàng/ký nhận không. Không tự đoán — xem chi tiết đầy đủ trong spec BA. - Consumable (2026-08-02): phần phân loại (`AssetCategory`, `ConsumableItem`) đã có spec đầy đủ (BA), sẵn sàng cho `pm` chia task. Luồng "xin + nhận" (`ConsumableRequest` hoặc tương đương) **BỊ CHẶN** — cần stakeholder trả lời các câu hỏi mở: (1) có cần approval không, ai duyệt; (2) ai được tạo request xin (tự xin cho mình hay xin hộ được); (3) có giới hạn số lượng tối đa/lần xin không; (4) hết hàng thì chặn hẳn / cho "nợ" chờ nhập thêm / fulfill một phần; (5) trừ tồn kho lúc tạo request hay lúc fulfill; (6) restock tồn kho qua luồng nào, ai được làm; (7) có cần ghi nhận người phát hàng/ký nhận không. Không tự đoán — xem chi tiết đầy đủ trong spec BA.
- Migrate 2 request đang có (`Firefly_License_Request.docx`, `Eric Pelatan.docx`, ở workspace `ubisoft/` ngoài repo này) vào hệ thống mới sau khi có DB - Migrate 2 request đang có (`Firefly_License_Request.docx`, `Eric Pelatan.docx`, ở workspace `ubisoft/` ngoài repo này) vào hệ thống mới sau khi có DB
- **`MaintenanceRecord` (spec BA + stakeholder, chốt 2026-08-04): đã có spec đầy đủ, sẵn sàng cho `pm` chia task** — xem mục "MaintenanceRecord" phía trên. Field cơ bản + `type` (`repair`/`warranty`/`preventive`) + `vendorId` (tái dùng `Vendor`, nullable) đã chốt; đồng bộ `AssetItem.status` đã chốt rõ (auto → `repair` lúc mở, chặn nếu có `Assignment` active hoặc đã `retired`, `targetStatus` bắt buộc lúc đóng); không track `cost`.
- (Xa hơn, chưa ưu tiên) Rack/Slot + IoT pick-to-light, cron email cảnh báo license hết hạn, Power BI report views - (Xa hơn, chưa ưu tiên) Rack/Slot + IoT pick-to-light, cron email cảnh báo license hết hạn, Power BI report views
- Setup Gitea tự host ở nhà (2026-07-31) — dự định làm remote chính sau này, giữ GitHub làm backup off-site (đây là "sản phẩm dùng cả đời" của user, không nên chỉ có 1 bản duy nhất). Tạm thời vẫn push GitHub như hiện tại, chưa làm gấp. - Gitea tự host (`git.toanhoang.work`) giờ đã là `origin` chính thức (đổi từ GitHub — cập nhật note cũ 2026-07-31 vốn nói "tạm thời vẫn push GitHub"; chưa xác nhận có còn remote GitHub backup off-site nào không, cần check lại). CI (build+test+tsc+lint qua Gitea Actions) đã setup 2026-08-20, xem `.gitea/workflows/ci.yml` — chỉ chạy trên PR/push, **chưa có auto-deploy**, deploy vẫn làm tay qua `docker compose up -d --build` + `npm run db:migrate` trên `appsrv01` (xem `CLAUDE.md`).
- **`InventoryScan` (kiểm kê move, chốt 2026-08-20): xem mục "InventoryScan — kiểm kê văn phòng move" phía trên.** Việc còn dở riêng của feature này: (1) chưa build bước "promote" 1 `InventoryScan` thành `AssetItem` thật (đọc serial thật, tạo `AssetItem` + optionally `Assignment`) — hiện review xong vẫn chỉ nằm ở `InventoryScan`; (2) chưa có OCR tự động đọc `assetTag`/`serialNumber` từ ảnh — stakeholder tự nhờ Claude đọc ảnh thủ công, nếu cần tự động hoá sau thì cần thêm `ANTHROPIC_API_KEY` riêng cho server + route gọi Claude Vision (đã bàn, chưa làm vì stakeholder chọn hướng thủ công trước); (3) `Area.team` đang trống cho toàn bộ `Area` đã tạo — cần điền khi có sơ đồ move chính thức.
## Ghi chú & bài học ## Ghi chú & bài học
- Migrate entity `Request` từ SQLite sang Postgres (2026-07-30) và setup ESLint đều đã **CONFIRMED xong** qua lượt verify độc lập (test-runner riêng, không phải agent vừa code tự chấm) — 6 test pass trên Postgres thật, lint sạch, type-check sạch. Chi tiết trạng thái nằm ở `../CLAUDE.md`, không duplicate ở đây. - Migrate entity `Request` từ SQLite sang Postgres (2026-07-30) và setup ESLint đều đã **CONFIRMED xong** qua lượt verify độc lập (test-runner riêng, không phải agent vừa code tự chấm) — 6 test pass trên Postgres thật, lint sạch, type-check sạch. Chi tiết trạng thái nằm ở `../CLAUDE.md`, không duplicate ở đây.
--- ---
Cập nhật lần cuối: 2026-08-02 (spec `Vendor`, `PurchaseOrder` gộp `Receipt`, `Assignment`, `AssetCategoryAttributeDef` — sẵn sàng cho `pm` chia task) Cập nhật lần cuối: 2026-08-20 (`InventoryScan` — kiểm kê move — chốt đủ + build xong qua Claude Code từ xa (SSH, không qua pipeline agent chuẩn của repo), CI Gitea Actions setup — xem "Việc còn dở")
@@ -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;
@@ -0,0 +1,40 @@
-- CreateTable
CREATE TABLE "areas" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"team" TEXT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "areas_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "areas_name_key" ON "areas"("name");
-- CreateTable
CREATE TABLE "inventory_scans" (
"id" UUID NOT NULL,
"category_id" UUID NOT NULL,
"model_id" UUID NOT NULL,
"area_id" UUID,
"assigned_employee_id" UUID,
"specs" JSONB NOT NULL DEFAULT '{}',
"photo_path" TEXT,
"telegram_chat_id" TEXT,
"telegram_message_id" INTEGER,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "inventory_scans_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "inventory_scans" ADD CONSTRAINT "inventory_scans_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "asset_categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventory_scans" ADD CONSTRAINT "inventory_scans_model_id_fkey" FOREIGN KEY ("model_id") REFERENCES "asset_models"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventory_scans" ADD CONSTRAINT "inventory_scans_area_id_fkey" FOREIGN KEY ("area_id") REFERENCES "areas"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventory_scans" ADD CONSTRAINT "inventory_scans_assigned_employee_id_fkey" FOREIGN KEY ("assigned_employee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "inventory_scans" ADD COLUMN "asset_tag" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "inventory_scans" ADD COLUMN "serial_number" TEXT;
@@ -0,0 +1,19 @@
-- Hand-written (same deviation as the other 2026-08-20 migrations in this
-- feature -- see CLAUDE.md "Việc còn dở"), not `prisma migrate dev`.
CREATE TABLE "expected_assets" (
"id" UUID NOT NULL,
"asset_tag" TEXT,
"model_text" TEXT NOT NULL,
"serial_number" TEXT,
"old_area_text" TEXT,
"old_assignee_text" TEXT,
"matched_scan_id" UUID,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "expected_assets_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "expected_assets_matched_scan_id_key" ON "expected_assets"("matched_scan_id");
ALTER TABLE "expected_assets" ADD CONSTRAINT "expected_assets_matched_scan_id_fkey"
FOREIGN KEY ("matched_scan_id") REFERENCES "inventory_scans"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- Hand-written (same deviation as other 2026-08-2x migrations in this
-- feature area -- see CLAUDE.md).
ALTER TABLE "asset_models" ADD COLUMN "warranty_months" INTEGER;
@@ -0,0 +1,3 @@
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
ALTER TABLE "asset_items" ADD COLUMN "asset_tag" TEXT;
CREATE UNIQUE INDEX "asset_items_asset_tag_key" ON "asset_items"("asset_tag");
@@ -0,0 +1,2 @@
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
ALTER TABLE "asset_items" ALTER COLUMN "purchase_date" DROP NOT NULL;
@@ -0,0 +1,4 @@
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
ALTER TABLE "asset_items" ADD COLUMN "cost" DECIMAL(14,2);
ALTER TABLE "asset_items" ADD COLUMN "cost_currency" TEXT;
ALTER TABLE "asset_items" ADD COLUMN "external_po_number" TEXT;
@@ -0,0 +1,2 @@
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
ALTER TYPE "AssetItemStatus" ADD VALUE 'lost';
@@ -0,0 +1,4 @@
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
ALTER TABLE "asset_items" ADD COLUMN "area_id" UUID;
ALTER TABLE "asset_items" ADD CONSTRAINT "asset_items_area_id_fkey"
FOREIGN KEY ("area_id") REFERENCES "areas"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
ALTER TYPE "AssetItemStatus" RENAME VALUE 'assigned' TO 'in_use';
+479 -13
View File
@@ -40,6 +40,15 @@ model Request {
// plain string (not an enum) until more states exist — see // plain string (not an enum) until more states exist — see
// src/lib/requests.ts's RequestStatus type. // src/lib/requests.ts's RequestStatus type.
status String @default("Draft") 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) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("requests") @@map("requests")
@@ -61,36 +70,123 @@ model Employee {
// Back-relation for PurchaseOrder.receivedById — an Employee can be the // Back-relation for PurchaseOrder.receivedById — an Employee can be the
// receiver on multiple POs over time. // receiver on multiple POs over time.
purchaseOrdersReceived PurchaseOrder[] 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[]
// Back-relation for InventoryScan.assignedEmployeeId — see InventoryScan's
// comment. An Employee can have multiple scanned items (workstation +
// monitor + controller, etc.) attributed to them from the office-move
// inventory walk.
inventoryScans InventoryScan[]
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("employees") @@map("employees")
} }
// Fixed, closed set of 4 values tied to the domain (see docs/ // Fixed, closed set of values tied to the domain (see docs/
// asset-license-management.md's "Data model (sơ bộ)" table) — native // asset-license-management.md's "Data model (sơ bộ)" table) — native
// Postgres enum, same rationale as RequestType above. // Postgres enum, same rationale as RequestType above. `lost` added
// 2026-08-25 (ALTER TYPE ... ADD VALUE, not a table -- see the
// 2026-08-24/25 discussion in CLAUDE.md on why this stays an enum: the
// values are read directly by business logic, e.g. createAssignment()'s
// `status !== "in_stock"` gate, not just display labels). `assigned`
// renamed to `in_use` same day (ALTER TYPE ... RENAME VALUE) -- "assigned"
// implied a specific person always holds it, but createAssignment() only
// requires in_stock -> the enum value itself, and ~158 real-imported items
// have this status with no Assignment row at all (shared/unowned
// equipment) -- "in_use" doesn't carry that false implication.
enum AssetItemStatus { enum AssetItemStatus {
in_stock in_stock
assigned in_use
repair repair
retired retired
lost
} }
// Hardware inventory — see docs/asset-license-management.md's "Data model // 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). // (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 // No Assignment relation yet — that entity doesn't exist yet (see "Việc còn
// (see "Việc còn dở" in CLAUDE.md), so this is a standalone entity for now, // dở" in CLAUDE.md). warrantyEndDate is nullable: not every asset has known
// same starting shape as Employee before Request grew an FK to it. // warranty terms at intake time (smallest sensible assumption — doesn't
// warrantyEndDate is nullable: not every asset has known warranty terms at // block creation). purchaseOrderId is a nullable FK to PurchaseOrder — see
// intake time (smallest sensible assumption — doesn't block creation). // 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 { model AssetItem {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
serialNumber String @unique @map("serial_number") serialNumber String @unique @map("serial_number")
model String // Company-assigned physical tag (e.g. "DANLAP001", printed on a sticker
purchaseDate DateTime @map("purchase_date") @db.Date // on the device) -- 2026-08-24, added when bulk-importing the real
// ServiceNow ITAM export (see docs/asset-license-management.md). Was
// only on InventoryScan before (the office-move walk feature); this is
// the same concept but on the actual asset record, not a move-day scan.
// Nullable (a manually-entered new purchase may not have one yet) but
// unique when present -- the real export had 613/613 unique, non-null
// tags, same integrity guarantee as serialNumber.
assetTag String? @unique @map("asset_tag")
modelId String @map("model_id") @db.Uuid
model AssetModel @relation(fields: [modelId], references: [id])
// Nullable as of 2026-08-24 (was NOT NULL) -- bulk-importing the real
// ITAM export, some legacy rows have no known purchase date at all (no
// "Order received" value in the source). Manual /assets create form
// still requires it (a NEW purchase should always have a known date);
// this only relaxes the DB/import path for historical data. Also means
// AssetModel.warrantyMonths auto-fill (see createAssetItem()) can't
// compute anything for these rows -- warrantyEndDate stays null too.
purchaseDate DateTime? @map("purchase_date") @db.Date
warrantyEndDate DateTime? @map("warranty_end_date") @db.Date warrantyEndDate DateTime? @map("warranty_end_date") @db.Date
status AssetItemStatus @default(in_stock) status AssetItemStatus @default(in_stock)
// Purchase cost, in `costCurrency`'s units -- added 2026-08-24 alongside
// externalPoNumber for the real ITAM export (very sparse there: 8/613
// rows had a nonzero cost). Decimal, not Float, for exact money math (no
// binary-float rounding drift) -- Decimal(14,2) comfortably covers VND
// amounts in the millions with room to spare.
cost Decimal? @db.Decimal(14, 2)
costCurrency String? @map("cost_currency")
// Reuses the Area entity built for InventoryScan/the office-move feature
// (2026-08-25) rather than a free-text location string -- same "share
// the lookup entity across tables" precedent floated for Department.
// Nullable: real-world coverage is thin (the ITAM export's own location
// data was nearly useless -- "Physical location" was the literal string
// "DAN" for all 613 rows, no per-item detail; only "Stockroom" carried
// any signal, and only for the ~71 in_stock items -- an in_use item is
// with a person, not sitting in a stockroom bin, so it has no area here
// by design, not by data gap).
areaId String? @map("area_id") @db.Uuid
area Area? @relation(fields: [areaId], references: [id], onDelete: SetNull)
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
// Free-text PO number from an external system (e.g. the real ITAM
// export's "PO number" column) -- deliberately NOT the same thing as
// purchaseOrderId above, which is a real FK into this app's own
// PurchaseOrder table. External exports' PO numbers are messy free text
// ("Not available", "Pending", or an actual number) with no matching
// PurchaseOrder row to link to here, so this just preserves the raw
// value for reference instead of forcing a fake relation.
externalPoNumber String? @map("external_po_number")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) 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") @@map("asset_items")
} }
@@ -113,7 +209,13 @@ enum LicenseType {
// at creation), not a computed value — it will only ever be set to 0 here // 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. // since Assignment (the thing that would consume seats) doesn't exist yet.
// `expiryDate` is nullable: perpetual licenses never expire, only // `expiryDate` is nullable: perpetual licenses never expire, only
// subscription licenses usually have one. // 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 { model License {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
name String name String
@@ -123,7 +225,13 @@ model License {
seatsUsed Int @default(0) @map("seats_used") seatsUsed Int @default(0) @map("seats_used")
expiryDate DateTime? @map("expiry_date") @db.Date expiryDate DateTime? @map("expiry_date") @db.Date
licenseType LicenseType @map("license_type") 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) 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") @@map("licenses")
} }
@@ -148,6 +256,10 @@ model Vendor {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
licenses License[] licenses License[]
purchaseOrders PurchaseOrder[] purchaseOrders PurchaseOrder[]
// Back-relation for MaintenanceRecord.vendorId — a Vendor can perform
// multiple repairs/warranty jobs over time, same reuse rationale as
// PurchaseOrder.vendorId (see MaintenanceRecord model's comment).
maintenanceRecords MaintenanceRecord[]
@@map("vendors") @@map("vendors")
} }
@@ -167,8 +279,14 @@ model Vendor {
// received) — enforced at the Zod layer (src/lib/purchase-orders.ts), not a // received) — enforced at the Zod layer (src/lib/purchase-orders.ts), not a
// DB constraint, same approach as other cross-field invariants in this // DB constraint, same approach as other cross-field invariants in this
// codebase (e.g. Assignment's assetItemId/licenseId XOR, not built yet). // codebase (e.g. Assignment's assetItemId/licenseId XOR, not built yet).
// purchaseOrderId back-references on AssetItem/License/Request (traceability, // `assetItems` is the back-relation from the AssetItem.purchaseOrderId
// same doc section) are NOT part of this change — out of scope for this task. // 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 { model PurchaseOrder {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
vendorId String @map("vendor_id") @db.Uuid vendorId String @map("vendor_id") @db.Uuid
@@ -179,10 +297,189 @@ model PurchaseOrder {
receivedById String? @map("received_by") @db.Uuid receivedById String? @map("received_by") @db.Uuid
receivedBy Employee? @relation(fields: [receivedById], references: [id]) receivedBy Employee? @relation(fields: [receivedById], references: [id])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
assetItems AssetItem[]
licenses License[]
requests Request[]
@@map("purchase_orders") @@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[]
// Back-relation for InventoryScan.categoryId — see InventoryScan's comment.
inventoryScans InventoryScan[]
@@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("{}")
// Standard manufacturer/vendor warranty length for this model, in months
// (e.g. Dell Latitude 5420 -> 36). Nullable -- not every model has a known
// standard term. Used only as a DEFAULT when creating an AssetItem: see
// src/lib/assets.ts's createAssetItem() -- warrantyEndDate = purchaseDate
// + warrantyMonths, computed server-side only when the caller doesn't
// supply warrantyEndDate explicitly (2026-08-21 decision: a specific
// purchase batch's actual warranty can differ from the model's standard
// term -- e.g. an extended-warranty deal -- so this must stay an
// overridable suggestion, not a hard-locked computed column on AssetItem).
warrantyMonths Int? @map("warranty_months")
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[]
// Back-relation for InventoryScan.modelId — see InventoryScan's comment.
inventoryScans InventoryScan[]
@@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, // Minimal internal auth accounts — NOT company SSO (deliberately ruled out,
// see CLAUDE.md / auth decision notes). A handful of trusted IT staff // see CLAUDE.md / auth decision notes). A handful of trusted IT staff
// accounts, created by hand via scripts/create-user.ts, no self-registration. // accounts, created by hand via scripts/create-user.ts, no self-registration.
@@ -200,3 +497,172 @@ model User {
@@map("users") @@map("users")
} }
// Fixed, closed set of 3 values tied to the domain (see docs/
// asset-license-management.md's "## MaintenanceRecord (spec BA + stakeholder,
// chốt 2026-08-04)" section) — native Postgres enum, same rationale as
// RequestType/AssetItemStatus/LicenseType/AssetCategoryValueGroup/
// AssetAttributeDataType above.
enum MaintenanceType {
repair
warranty
preventive
}
// Records a repair/warranty/preventive-maintenance job for a specific
// AssetItem — see docs/asset-license-management.md's "## MaintenanceRecord
// (spec BA + stakeholder, chốt 2026-08-04)" section. Only applies to
// AssetItem (hardware) — License has no physical-repair concept, so unlike
// Assignment there's no XOR here: assetItemId is the single, always-required
// target. `vendorId` reuses the existing Vendor entity (same "external
// counterparty" concept as PurchaseOrder.vendorId) and is nullable — null
// means an internal repair with no outside vendor involved. No `cost` field,
// per the same precedent as PurchaseOrder (stakeholder explicitly said no
// money tracking there). No separate `status` field either — "open" vs
// "done" is derived from `endDate` (null = still being worked on, set = done),
// same precedent as PurchaseOrder.receivedAt not duplicating state. `endDate
// >= startDate` and "at most one OPEN (endDate IS NULL) MaintenanceRecord per
// AssetItem" are both business invariants enforced at the application layer
// (src/lib/maintenance-records.ts, not built in this task) — NOT DB
// constraints, per the same convention as Assignment's XOR / "at most one
// active" invariant (see Assignment model's comment above for the mirrored
// trade-off). No FK to User for "who logged this" — no existing precedent in
// this codebase ties a business entity to User (User is login-only), see
// doc for this explicit call.
model MaintenanceRecord {
id String @id @default(uuid()) @db.Uuid
assetItemId String @map("asset_item_id") @db.Uuid
assetItem AssetItem @relation(fields: [assetItemId], references: [id])
type MaintenanceType
vendorId String? @map("vendor_id") @db.Uuid
vendor Vendor? @relation(fields: [vendorId], references: [id])
issueDescription String @map("issue_description")
resolutionNotes String? @map("resolution_notes")
startDate DateTime @map("start_date") @db.Date
endDate DateTime? @map("end_date") @db.Date
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("maintenance_records")
}
// Physical location tag for the office-move inventory walk — see the
// "chia theo area" decision in the inventory-scans project chat. Deliberately
// NOT the pre-existing high-value AssetItem's target ("Seat" tied 1:1 to a
// Employee/team) because that data (the new office seating chart) doesn't
// exist yet at the time this was built — `team` is nullable and gets filled
// in once the seating chart is finalized, no schema change needed then.
// Covers both per-seat locations ("Seat 12") and shared-room locations
// ("Phòng họp A", "Kho tầng 2") with the same flat entity — splitting seat
// vs room into separate types was considered and rejected as premature; add
// a `type` column later if filtering by kind actually becomes necessary.
// `name` is globally unique so the bot's "reuse existing or create new" flow
// (grows the list as the walk progresses, avoids duplicate near-identical
// area names from free-typing) has something to match against.
model Area {
id String @id @default(uuid()) @db.Uuid
name String @unique
team String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
inventoryScans InventoryScan[]
// Back-relation for AssetItem.areaId -- see that field's comment.
assetItems AssetItem[]
@@map("areas")
}
// One physical item logged during the office-move inventory walk (bot flow:
// Category -> Model -> [Workstation only: cpu/ram/gpu] -> Area -> assignee ->
// optional photo). Deliberately NOT a row in AssetItem: AssetItem requires a
// unique serialNumber and a purchaseDate, neither of which is known/at-hand
// during a fast walk-and-tap pass — forcing either would either block the
// walk or invent fake placeholder data, which this codebase avoids elsewhere
// (see AssetItem/License/PurchaseOrder comments' "no fake data" precedent).
// InventoryScan is the lightweight capture; promoting a scan to a real
// AssetItem (with a real serial once read off the physical tag) is a manual
// follow-up step done later in the review UI, not built in this task.
// `categoryId`/`modelId` reuse the existing AssetCategory/AssetModel catalog
// (seeded with Controller/Màn hình/Workstation for this task's scope) rather
// than inventing a parallel classification. `areaId` and `assignedEmployeeId`
// are both nullable — plenty of scanned items are shared equipment in a room
// (no single assignee) or not yet placed in a known Area. `specs` is JSONB,
// same shape/rationale as AssetModel.specs — holds the Workstation-only
// cpu/ram/gpu answers; empty object for categories that don't need them.
// `telegramChatId`/`telegramMessageId` are kept for traceability back to the
// original bot conversation (debugging a bad scan, or re-fetching the photo
// from Telegram if `photoPath` is ever lost) — nullable since a scan created
// directly from the review UI later won't have either.
model InventoryScan {
id String @id @default(uuid()) @db.Uuid
categoryId String @map("category_id") @db.Uuid
category AssetCategory @relation(fields: [categoryId], references: [id])
modelId String @map("model_id") @db.Uuid
model AssetModel @relation(fields: [modelId], references: [id])
areaId String? @map("area_id") @db.Uuid
area Area? @relation(fields: [areaId], references: [id])
assignedEmployeeId String? @map("assigned_employee_id") @db.Uuid
assignedEmployee Employee? @relation(fields: [assignedEmployeeId], references: [id])
// Physical asset tag/barcode label already stuck on the device (see the
// "Scan QR/barcode đã có sẵn trên thiết bị" scope decision). Free text,
// typed by the bot operator for now — not yet an actual barcode-image
// decode. Deliberately NOT unique/required at this table's level (unlike
// AssetItem.serialNumber): plenty of scanned items have no tag at all
// (Controllers, Monitors bought without one), and enforcing uniqueness
// here would block the walk on a typo'd re-entry instead of just letting
// review-time cleanup catch it, per this model's "don't block the walk"
// precedent (see AssetItem-vs-InventoryScan comment above).
assetTag String? @map("asset_tag")
// Manufacturer serial number, read off the same physical tag as
// assetTag — same "don't block the walk" rationale (nullable, not
// unique at this table's level; AssetItem.serialNumber is the unique
// one, enforced only once a scan is promoted). Filled in either by
// typing, or later by whoever reviews the scan's photo (bot operator or
// Claude) reading it off the image — no OCR automation built yet, this
// column is just where that value lands once read.
serialNumber String? @map("serial_number")
specs Json @default("{}")
// Stores the Telegram file_id (not a local disk path, despite the DB
// column name) — photos live on Telegram's servers, re-fetched on demand
// via GET /api/inventory-scans/[id]/photo rather than duplicated onto
// this app's own disk. Avoids a shared volume mount between this
// container and the separate inventory-capture bot service.
photoFileId String? @map("photo_path")
telegramChatId String? @map("telegram_chat_id")
telegramMessageId Int? @map("telegram_message_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Back-reference for ExpectedAsset.matchedScanId -- set once someone
// picks this scan's pre-uploaded row via the bot's "🔍 Tìm trong list"
// step. No @relation attribute needed here; the FK lives on the other
// side.
expectedAsset ExpectedAsset?
@@map("inventory_scans")
}
// A row from a pre-move Excel/CSV export of known assets (asset tag,
// model, old area/owner), uploaded via POST /api/expected-assets/import
// before an office-move walk starts (2026-08-21 decision -- team decided
// plain manual entry was faster/more reliable than local OCR for ~300-400
// assets, see docs/asset-license-management.md, but typing in a KNOWN
// asset tag from scratch during the walk is still wasted effort when it's
// sitting right here). Purely a lookup/staging table for the bot's "🔍 Tìm
// trong list" picker -- modelText/oldAreaText/oldAssigneeText are free
// text straight from the import file, deliberately NOT FK'd into
// AssetModel/Area/Employee: import-file text won't reliably match this
// app's catalog IDs/spelling, and forcing that match at import time would
// block the whole import on one typo instead of just being a soft
// "here's the old value" suggestion during the walk. Marked matched (via
// matchedScanId) once someone actually picks it while creating a scan, so
// it stops showing up in future searches.
model ExpectedAsset {
id String @id @default(uuid()) @db.Uuid
assetTag String? @map("asset_tag")
modelText String @map("model_text")
serialNumber String? @map("serial_number")
oldAreaText String? @map("old_area_text")
oldAssigneeText String? @map("old_assignee_text")
matchedScanId String? @unique @map("matched_scan_id") @db.Uuid
matchedScan InventoryScan? @relation(fields: [matchedScanId], references: [id])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("expected_assets")
}
+79 -3
View File
@@ -1,8 +1,12 @@
// Standalone CLI entry point for creating an internal auth account by hand: // Standalone CLI entry point for creating an internal auth account by hand:
// `npx tsx scripts/create-user.ts --email [email protected] --name "Foo Bar" --password "..."` // `npx tsx scripts/create-user.ts --email [email protected] --name "Foo Bar"`
// (you'll be prompted for the password interactively — see readPassword()
// below; it is deliberately never accepted via a CLI flag, to avoid leaking
// it through shell history / `ps` / process listings).
// There is deliberately no self-registration UI or public API route for // There is deliberately no self-registration UI or public API route for
// this — see src/lib/users.ts / prisma/schema.prisma's User model comment // this — see src/lib/users.ts / prisma/schema.prisma's User model comment
// for why (a handful of trusted IT staff accounts, not company-wide). // for why (a handful of trusted IT staff accounts, not company-wide).
import readline from "readline";
import { createUser, validateCreateUserInput } from "../src/lib/users"; import { createUser, validateCreateUserInput } from "../src/lib/users";
import { prisma } from "../src/lib/prisma"; import { prisma } from "../src/lib/prisma";
@@ -24,19 +28,91 @@ function parseArgs(argv: string[]): Record<string, string> {
return args; return args;
} }
/**
* Prompts for a password on stdin without echoing the typed characters back
* to the terminal (each keystroke is masked with `*`). Only Node built-ins
* (`readline` + `process.stdin` raw mode) — no extra dependency.
*
* Falls back to a plain (unmasked) `readline` question when stdin isn't a
* TTY (e.g. input piped in for scripting/testing) since raw mode requires a
* real terminal.
*/
function readPassword(promptText: string): Promise<string> {
const stdin = process.stdin;
const stdout = process.stdout;
if (!stdin.isTTY) {
return new Promise((resolve) => {
const rl = readline.createInterface({ input: stdin, output: stdout, terminal: false });
rl.question(promptText, (answer) => {
rl.close();
resolve(answer);
});
});
}
return new Promise((resolve, reject) => {
stdout.write(promptText);
stdin.resume();
stdin.setRawMode(true);
stdin.setEncoding("utf8");
let password = "";
const cleanup = () => {
stdin.setRawMode(false);
stdin.pause();
stdin.removeListener("data", onData);
};
const onData = (chunk: string) => {
switch (chunk) {
case "\n":
case "\r":
case "\u0004": // Ctrl-D
cleanup();
stdout.write("\n");
resolve(password);
break;
case "\u0003": // Ctrl-C
cleanup();
stdout.write("\n");
reject(new Error("Aborted"));
break;
case "\u007f": // Backspace (most terminals)
case "\b":
if (password.length > 0) {
password = password.slice(0, -1);
stdout.write("\b \b");
}
break;
default:
password += chunk;
stdout.write("*");
break;
}
};
stdin.on("data", onData);
});
}
async function main(): Promise<void> { async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2)); const args = parseArgs(process.argv.slice(2));
const password = await readPassword("Password: ");
const validation = validateCreateUserInput({ const validation = validateCreateUserInput({
email: args.email, email: args.email,
name: args.name, name: args.name,
password: args.password, password,
}); });
if (!validation.ok) { if (!validation.ok) {
console.error(`Error: ${validation.error}`); console.error(`Error: ${validation.error}`);
console.error( console.error(
'Usage: npx tsx scripts/create-user.ts --email [email protected] --name "Foo Bar" --password "..."' 'Usage: npx tsx scripts/create-user.ts --email [email protected] --name "Foo Bar" (password prompted interactively)'
); );
process.exitCode = 1; process.exitCode = 1;
return; return;
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { createOrGetArea, listAreas, validateCreateAreaInput } from "@/lib/areas";
// Session-authed Area list/create for the review page — mirrors
// src/app/api/employees/route.ts's pattern. The bot uses the separate
// bot-secret-authed /api/bot/areas instead (see src/lib/bot-auth.ts).
export async function GET(): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const areas = await listAreas();
return NextResponse.json(areas, { status: 200 });
}
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, team } = body as Record<string, unknown>;
const validation = validateCreateAreaInput({ name, team });
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createOrGetArea(validation.value);
if (!created.ok) {
return NextResponse.json({ error: created.error }, { status: 400 });
}
return NextResponse.json(created.value, { status: 201 });
}
+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 });
}
+58
View File
@@ -0,0 +1,58 @@
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, warrantyMonths } = body as Record<string, unknown>;
const validation = validateCreateAssetModelInput({
categoryId,
manufacturer,
name,
specs,
warrantyMonths,
});
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 });
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { exportAssetItemsCsv } from "@/lib/assets";
// Session-authed CSV export -- a plain GET so a normal <a href> download
// link works (browser sends the NextAuth session cookie on same-origin
// navigation same as any other page load; no need for a fetch+blob dance
// client-side). Columns match src/lib/assets.ts's parseAssetItemsCsv() so
// export -> edit -> re-import is a real round trip.
export async function GET(): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const csv = await exportAssetItemsCsv();
return new NextResponse(csv, {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="asset-items.csv"',
},
});
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { importAssetItems, parseAssetItemsCsv } from "@/lib/assets";
// Session-authed CSV bulk import (see src/app/assets/page.tsx's import
// section). Same "raw text body, no multipart" shape as
// src/app/api/expected-assets/import/route.ts -- the upload UI reads the
// File client-side (File.text()) and posts the text directly.
export async function POST(request: Request): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const text = await request.text();
if (!text || text.trim() === "") {
return NextResponse.json({ error: "Request body must be non-empty CSV text" }, { status: 400 });
}
const parsed = parseAssetItemsCsv(text);
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 });
}
if (parsed.rows.length === 0) {
return NextResponse.json({ error: "No data rows found after the header" }, { status: 400 });
}
const result = await importAssetItems(parsed.rows);
return NextResponse.json(result, { status: 201 });
}
+54 -7
View File
@@ -1,6 +1,11 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { createAssetItem, listAssetItems, validateCreateAssetItemInput } from "@/lib/assets"; import {
createAssetItem,
listAssetItems,
validateCreateAssetItemInput,
type AssetItemStatus,
} from "@/lib/assets";
// Mirrors src/app/api/employees/route.ts's pattern — middleware.ts excludes // Mirrors src/app/api/employees/route.ts's pattern — middleware.ts excludes
// /api from route protection, so this checks the session itself. Flat // /api from route protection, so this checks the session itself. Flat
@@ -22,12 +27,29 @@ export async function POST(request: Request): Promise<NextResponse> {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
} }
const { serialNumber, model, purchaseDate, warrantyEndDate } = body as Record<string, unknown>; const {
const validation = validateCreateAssetItemInput({
serialNumber, serialNumber,
model, assetTag,
modelId,
purchaseDate, purchaseDate,
warrantyEndDate, warrantyEndDate,
purchaseOrderId,
cost,
costCurrency,
externalPoNumber,
areaId,
} = body as Record<string, unknown>;
const validation = validateCreateAssetItemInput({
serialNumber,
assetTag,
modelId,
purchaseDate,
warrantyEndDate,
purchaseOrderId,
cost,
costCurrency,
externalPoNumber,
areaId,
}); });
if (!validation.ok) { if (!validation.ok) {
@@ -42,12 +64,37 @@ export async function POST(request: Request): Promise<NextResponse> {
return NextResponse.json(created.value, { status: 201 }); return NextResponse.json(created.value, { status: 201 });
} }
export async function GET(): Promise<NextResponse> { // The one entity route in this app whose GET response is NOT a bare array
// (see src/hooks/use-api-list.ts's doc comment on that being the norm
// everywhere else) -- it's `{items, total}` instead, because /assets is
// paginated (2026-08-25, see listAssetItems()'s doc comment) and the
// client needs `total` to render page controls. src/app/assets/page.tsx
// fetches this directly rather than through useApiList for that reason.
export async function GET(request: Request): Promise<NextResponse> {
const session = await auth(); const session = await auth();
if (!session) { if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
const assetItems = await listAssetItems(); const url = new URL(request.url);
return NextResponse.json(assetItems, { status: 200 }); const params = url.searchParams;
const page = params.has("page") ? Number(params.get("page")) : undefined;
const pageSize = params.has("pageSize") ? Number(params.get("pageSize")) : undefined;
const status = params.get("status") ?? undefined;
const modelId = params.get("modelId") ?? undefined;
const categoryId = params.get("categoryId") ?? undefined;
const areaId = params.get("areaId") ?? undefined;
const search = params.get("search") ?? undefined;
const validStatuses = ["in_stock", "in_use", "repair", "retired", "lost"];
const result = await listAssetItems({
page,
pageSize,
status: status && validStatuses.includes(status) ? (status as AssetItemStatus) : undefined,
modelId,
categoryId,
areaId,
search,
});
return NextResponse.json(result, { status: 200 });
} }
+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 });
}
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { requireBotAuth } from "@/lib/bot-auth";
import { createOrGetArea, listAreas, validateCreateAreaInput } from "@/lib/areas";
// Bot-facing Area list/create — see src/lib/areas.ts's createOrGetArea()
// comment for why POST is idempotent-by-name rather than erroring on a
// name the bot's own keyboard just offered as an existing option.
export async function GET(request: Request): Promise<NextResponse> {
if (!requireBotAuth(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const areas = await listAreas();
return NextResponse.json({ areas });
}
export async function POST(request: Request): Promise<NextResponse> {
if (!requireBotAuth(request)) {
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, team } = body as Record<string, unknown>;
const validation = validateCreateAreaInput({ name, team });
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createOrGetArea(validation.value);
if (!created.ok) {
return NextResponse.json({ error: created.error }, { status: 400 });
}
return NextResponse.json(created.value, { status: 201 });
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { requireBotAuth } from "@/lib/bot-auth";
import { createAssetModel, validateCreateAssetModelInput } from "@/lib/asset-models";
// Bot-facing AssetModel create — lets the bot's "➕ Model mới" option add a
// model to an existing category on the fly (e.g. a new Laptop model),
// mirroring src/app/api/bot/areas & employees' "reuse or create" pattern.
// Categories themselves are still admin-seeded, not bot-creatable — a new
// category is a bigger decision (may need AssetCategoryAttributeDef rows
// too) than a same-category model addition.
export async function POST(request: Request): Promise<NextResponse> {
if (!requireBotAuth(request)) {
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 } = 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 });
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { requireBotAuth } from "@/lib/bot-auth";
import { prisma } from "@/lib/prisma";
// One-shot catalog fetch for the bot's Category -> Model inline-keyboard
// step, plus each category's attribute defs (currently only Workstation
// has any) so the bot knows which extra questions (cpu/ram/gpu) to ask
// after a model in that category is picked. Bot-secret auth, not session —
// see src/lib/bot-auth.ts.
export async function GET(request: Request): Promise<NextResponse> {
if (!requireBotAuth(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const categories = await prisma.assetCategory.findMany({
include: {
assetModels: { orderBy: [{ manufacturer: "asc" }, { name: "asc" }] },
attributeDefs: { orderBy: [{ sortOrder: "asc" }] },
},
orderBy: [{ name: "asc" }],
});
return NextResponse.json({
categories: categories.map((c) => ({
id: c.id,
name: c.name,
models: c.assetModels.map((m) => ({ id: m.id, name: m.name, manufacturer: m.manufacturer })),
attribute_defs: c.attributeDefs.map((d) => ({
key: d.key,
label: d.label,
data_type: d.dataType,
})),
})),
});
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { requireBotAuth } from "@/lib/bot-auth";
import { createEmployee, listEmployees, validateCreateEmployeeInput } from "@/lib/employees";
// Bot-facing Employee list/create for the "assign to" step. `department`
// defaults to a placeholder here (not in src/lib/employees.ts itself, which
// keeps requiring it — this is a bot-flow-only convenience: the walk needs
// speed, department can be corrected later in the review UI) since the bot
// only asks for a name, not a full HR record.
export async function GET(request: Request): Promise<NextResponse> {
if (!requireBotAuth(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const employees = await listEmployees();
return NextResponse.json({ employees });
}
export async function POST(request: Request): Promise<NextResponse> {
if (!requireBotAuth(request)) {
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, department } = body as Record<string, unknown>;
const validation = validateCreateEmployeeInput({
name,
department: department || "Chưa cập nhật",
});
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createEmployee(validation.value);
if (!created.ok) {
return NextResponse.json({ error: created.error }, { status: 400 });
}
return NextResponse.json(created.value, { status: 201 });
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { requireBotAuth } from "@/lib/bot-auth";
import { searchExpectedAssets } from "@/lib/expected-assets";
// Bot-facing search for the "🔍 Tìm trong list" picker (see
// src/lib/expected-assets.ts's searchExpectedAssets() for the matching
// rule). ?q= is optional -- an empty/missing query returns the most
// recently imported unmatched rows, which is what the bot shows first
// before the operator types anything to narrow it down.
export async function GET(request: Request): Promise<NextResponse> {
if (!requireBotAuth(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const q = url.searchParams.get("q") ?? undefined;
const expectedAssets = await searchExpectedAssets(q);
return NextResponse.json({ expectedAssets });
}
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { requireBotAuth } from "@/lib/bot-auth";
import { createInventoryScan, validateCreateInventoryScanInput } from "@/lib/inventory-scans";
// Bot-facing scan ingest — the actual "save this item" call at the end of
// the Telegram bot's Category -> Model -> [specs] -> Area -> assignee flow.
export async function POST(request: Request): Promise<NextResponse> {
if (!requireBotAuth(request)) {
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,
modelId,
areaId,
assignedEmployeeId,
assetTag,
serialNumber,
specs,
photoFileId,
telegramChatId,
telegramMessageId,
expectedAssetId,
} = body as Record<string, unknown>;
const validation = validateCreateInventoryScanInput({
categoryId,
modelId,
areaId,
assignedEmployeeId,
assetTag,
serialNumber,
specs,
photoFileId,
telegramChatId,
telegramMessageId,
expectedAssetId,
});
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createInventoryScan(validation.value);
if (!created.ok) {
return NextResponse.json({ error: created.error }, { status: 400 });
}
return NextResponse.json(created.value, { status: 201 });
}
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { importExpectedAssets, parseExpectedAssetsCsv } from "@/lib/expected-assets";
// Session-authed CSV upload (see src/app/inventory-scans/import/page.tsx) --
// the pre-move "here's what we already know" list, uploaded once before an
// office-move walk starts. Not bot-facing: this is prep work the operator
// does at a laptop before heading out, not something the Telegram flow
// itself triggers. Request body is the raw CSV text, not JSON/multipart --
// the upload page reads the File client-side (File.text()) and posts the
// text directly, same as this repo's other "small file, no need for real
// multipart handling" routes.
export async function POST(request: Request): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const text = await request.text();
if (!text || text.trim() === "") {
return NextResponse.json({ error: "Request body must be non-empty CSV text" }, { status: 400 });
}
const parsed = parseExpectedAssetsCsv(text);
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 });
}
if (parsed.rows.length === 0) {
return NextResponse.json({ error: "No data rows found after the header" }, { status: 400 });
}
const result = await importExpectedAssets(parsed.rows);
return NextResponse.json({ imported: result.count }, { status: 201 });
}
@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getInventoryScan } from "@/lib/inventory-scans";
// Streams the scan's photo on demand straight from Telegram's file storage
// — see prisma/schema.prisma's InventoryScan.photoFileId comment for why
// this app doesn't keep its own copy on disk. Telegram's getFile file_path
// is short-lived, so this re-resolves it on every request rather than
// caching a URL.
export async function GET(
request: Request,
{ params }: { params: { id: string } }
): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const scan = await getInventoryScan(params.id);
if (!scan || !scan.photo_file_id) {
return NextResponse.json({ error: "No photo for this scan" }, { status: 404 });
}
const botToken = process.env.BOT_TOKEN;
if (!botToken) {
return NextResponse.json({ error: "BOT_TOKEN not configured" }, { status: 500 });
}
const fileResp = await fetch(
`https://api.telegram.org/bot${botToken}/getFile?file_id=${encodeURIComponent(scan.photo_file_id)}`
);
const fileJson = await fileResp.json();
if (!fileJson.ok) {
return NextResponse.json({ error: "Telegram getFile failed" }, { status: 502 });
}
const filePath = fileJson.result.file_path as string;
const imageResp = await fetch(`https://api.telegram.org/file/bot${botToken}/${filePath}`);
if (!imageResp.ok || !imageResp.body) {
return NextResponse.json({ error: "Failed to fetch photo from Telegram" }, { status: 502 });
}
// Telegram serves file content as generic application/octet-stream
// regardless of the actual file type, which makes browsers download
// instead of display it inline — derive the real content type from the
// file_path extension instead of trusting imageResp's header.
const extension = filePath.split(".").pop()?.toLowerCase();
const contentType =
extension === "png"
? "image/png"
: extension === "webp"
? "image/webp"
: "image/jpeg";
return new NextResponse(imageResp.body, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Disposition": "inline",
"Cache-Control": "private, max-age=3600",
},
});
}
+76
View File
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import {
deleteInventoryScan,
updateInventoryScan,
validateUpdateInventoryScanInput,
} from "@/lib/inventory-scans";
// Review-page edit/delete — session-authed, mirrors
// src/app/api/assignments/[id]/route.ts's pattern (synchronous params,
// explicit UUID pre-check before hitting Prisma).
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 });
}
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 { modelId, areaId, assignedEmployeeId, assetTag, serialNumber, specs } =
body as Record<string, unknown>;
const validation = validateUpdateInventoryScanInput({
modelId,
areaId,
assignedEmployeeId,
assetTag,
serialNumber,
specs,
});
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const updated = await updateInventoryScan(params.id, validation.value);
if (!updated.ok) {
const status = updated.error === "InventoryScan not found" ? 404 : 400;
return NextResponse.json({ error: updated.error }, { status });
}
return NextResponse.json(updated.value, { status: 200 });
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!z.string().uuid().safeParse(params.id).success) {
return NextResponse.json({ error: "id must be a valid UUID string" }, { status: 400 });
}
const result = await deleteInventoryScan(params.id);
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: 404 });
}
return new NextResponse(null, { status: 204 });
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { listInventoryScans } from "@/lib/inventory-scans";
// Review-page read — session-authed like every other human-facing route
// (src/app/api/assets/route.ts's GET is the mirrored pattern). Create is
// bot-only (POST /api/bot/inventory-scans); edit/delete are
// /api/inventory-scans/[id].
export async function GET(): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const scans = await listInventoryScans();
return NextResponse.json(scans, { status: 200 });
}
+3 -1
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 }); return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
} }
const { name, vendorId, seatsTotal, expiryDate, licenseType } = body as Record<string, unknown>; const { name, vendorId, seatsTotal, expiryDate, licenseType, purchaseOrderId } =
body as Record<string, unknown>;
const validation = validateCreateLicenseInput({ const validation = validateCreateLicenseInput({
name, name,
vendorId, vendorId,
seatsTotal, seatsTotal,
expiryDate, expiryDate,
licenseType, licenseType,
purchaseOrderId,
}); });
if (!validation.ok) { if (!validation.ok) {
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import {
closeMaintenanceRecord,
validateCloseMaintenanceRecordInput,
} from "@/lib/maintenance-records";
// Mirrors src/app/api/assignments/[id]/route.ts's pattern — PATCH is the
// only update action for MaintenanceRecord ("close"), no dispatch-on-body
// needed here since there is no second action shape (unlike Assignment's
// return-vs-transfer). Next.js 14 App Router route handler params are a
// plain synchronous object (not a Promise) — see the Assignment route's
// same comment for why.
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Validate the [id] path segment's format up front — an invalid UUID
// passed straight to Prisma's `findUnique({ where: { id } })` (a `@db.Uuid`
// column) throws a raw PrismaClientKnownRequestError that
// closeMaintenanceRecord()'s try/catch doesn't specifically handle, which
// would otherwise leak as an uncaught 500 instead of a clean 400.
if (!z.string().uuid().safeParse(params.id).success) {
return NextResponse.json({ error: "id must be a valid UUID string" }, { status: 400 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
}
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { endDate, resolutionNotes, targetStatus } = body as Record<string, unknown>;
const validation = validateCloseMaintenanceRecordInput({
endDate,
resolutionNotes,
targetStatus,
});
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const closed = await closeMaintenanceRecord(params.id, validation.value);
if (!closed.ok) {
return NextResponse.json({ error: closed.error }, { status: closed.status });
}
return NextResponse.json(closed.value, { status: 200 });
}
+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 });
}
+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 }); return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
} }
const { employee_id, type, reason } = body as Record<string, unknown>; const { employee_id, type, reason, purchase_order_id } = body as Record<string, unknown>;
const validation = validateCreateRequestInput({ employee_id, type, reason }); const validation = validateCreateRequestInput({ employee_id, type, reason, purchase_order_id });
if (!validation.ok) { if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 }); return NextResponse.json({ error: validation.error }, { status: 400 });
+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>
);
}
+257
View File
@@ -0,0 +1,257 @@
"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>;
warranty_months: number | null;
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 [warrantyMonths, setWarrantyMonths] = 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);
// 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;
}
const trimmedWarranty = warrantyMonths.trim();
if (trimmedWarranty !== "" && Number.isNaN(Number(trimmedWarranty))) {
setSubmitError("Warranty (months) must be a number");
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,
...(trimmedWarranty !== "" ? { warrantyMonths: Number(trimmedWarranty) } : {}),
}),
});
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);
setWarrantyMonths("");
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>Warranty (months)</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.warranty_months ?? "-"}</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>
<div className="flex flex-col gap-2">
<Label htmlFor="warrantyMonths">Standard Warranty (months, optional)</Label>
<Input
id="warrantyMonths"
type="number"
min={1}
step={1}
value={warrantyMonths}
onChange={(event) => setWarrantyMonths(event.target.value)}
placeholder="e.g. 36"
/>
<p className="text-xs text-muted-foreground">
Used as a default when creating an Asset Item with this model -- warranty end date
is auto-filled from purchase date if left blank there, still editable per item.
</p>
</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>
);
}
+801
View File
@@ -0,0 +1,801 @@
"use client";
import { useEffect, useMemo, 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;
asset_tag: string | null;
model_id: string;
purchase_date: string | null;
warranty_end_date: string | null;
status: "in_stock" | "in_use" | "repair" | "retired" | "lost";
cost: number | null;
cost_currency: string | null;
area_id: string | null;
purchase_order_id: string | null;
external_po_number: string | null;
created_at: string;
}
// Shape returned by GET /api/areas — reused from the office-move feature
// (see src/lib/areas.ts). Only id/name needed here.
interface AreaRecord {
id: string;
name: string;
}
// Shape returned by GET /api/employees — only id/name needed to resolve
// the "Assigned To" column/search below.
interface EmployeeRecord {
id: string;
name: string;
}
// Shape returned by GET /api/assignments — only the fields needed to find
// each AssetItem's current (non-returned) assignee.
interface AssignmentRecord {
employee_id: string;
asset_item_id: string | null;
returned_at: string | null;
}
// 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). category_id added 2026-08-25, used
// to narrow the Model filter dropdown once a Category is picked -- with
// 135 models after the 2026-08-24 real-data import, an unfiltered list is
// unwieldy.
interface AssetModelRecord {
id: string;
category_id: string;
manufacturer: string;
name: string;
}
// Shape returned by GET /api/asset-categories — only id/name, used to
// populate the Category filter <Select> here.
interface AssetCategoryRecord {
id: 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";
// Same sentinel pattern as NO_PURCHASE_ORDER_VALUE above, for the optional
// Area <Select> in the create form.
const NO_AREA_VALUE = "none";
// Renders an ISO date(-time) string as mm/dd/yyyy, or "-" if absent.
// UTC-based (not local time) to match how purchaseDate/warrantyEndDate are
// actually stored -- see src/lib/assets.ts's dateString()/addMonthsClamped()
// comments on why this app treats these as UTC-midnight values throughout,
// not local-timezone dates.
function formatDateMMDDYYYY(isoString: string | null): string {
if (!isoString) return "-";
const date = new Date(isoString);
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
const yyyy = date.getUTCFullYear();
return `${mm}/${dd}/${yyyy}`;
}
// "Manufacturer Name" for display -- except a chunk of AssetModel rows
// from the 2026-08-24 real-data import already have the manufacturer
// baked into `name` (the source file's own "Model" column did this for
// some rows, e.g. name="Logitech Group" with manufacturer="Logitech"),
// which without this check renders as "Logitech Logitech Group". Skip the
// manufacturer prefix when `name` already starts with it, case-insensitive.
function formatModelLabel(manufacturer: string, name: string): string {
const normalizedName = name.trim().toLowerCase();
const normalizedManufacturer = manufacturer.trim().toLowerCase();
if (normalizedManufacturer && normalizedName.startsWith(normalizedManufacturer)) {
return name;
}
return `${manufacturer} ${name}`;
}
const ASSET_ITEMS_PAGE_SIZE = 50;
export default function AssetsPage() {
const { data: assetModels } = useApiList<AssetModelRecord>("/api/asset-models");
const { data: assetCategories } = useApiList<AssetCategoryRecord>("/api/asset-categories");
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
const { data: areas } = useApiList<AreaRecord>("/api/areas");
const { data: employees } = useApiList<EmployeeRecord>("/api/employees");
const { data: assignments } = useApiList<AssignmentRecord>("/api/assignments");
const [serialNumber, setSerialNumber] = useState("");
const [assetTag, setAssetTag] = useState("");
const [modelId, setModelId] = useState("");
const [purchaseDate, setPurchaseDate] = useState("");
const [warrantyEndDate, setWarrantyEndDate] = useState("");
const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE);
const [areaId, setAreaId] = useState(NO_AREA_VALUE);
const [cost, setCost] = useState("");
const [costCurrency, setCostCurrency] = useState("");
const [externalPoNumber, setExternalPoNumber] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// Filter/search + pagination state (2026-08-25 -- was client-side-only
// filtering over the full ~600+-row table; moved server-side, see
// src/lib/assets.ts's listAssetItems() doc comment for why). searchText
// is debounced into debouncedSearchText so typing doesn't fire a
// request per keystroke.
const [searchText, setSearchText] = useState("");
const [debouncedSearchText, setDebouncedSearchText] = useState("");
const [filterStatus, setFilterStatus] = useState("all");
const [filterCategoryId, setFilterCategoryId] = useState("all");
const [filterModelId, setFilterModelId] = useState("all");
const [filterAreaId, setFilterAreaId] = useState("all");
const [page, setPage] = useState(1);
const [assetItems, setAssetItems] = useState<AssetItemRecord[]>([]);
const [assetItemsTotal, setAssetItemsTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
function refetch() {
setRefreshKey((k) => k + 1);
}
useEffect(() => {
const timeout = setTimeout(() => setDebouncedSearchText(searchText.trim()), 300);
return () => clearTimeout(timeout);
}, [searchText]);
// A filter changing invalidates whatever page we were on -- e.g. page 3
// of an unfiltered list may not exist at all once a filter narrows the
// result set. Reset to page 1 rather than showing an empty/stale page.
useEffect(() => {
setPage(1);
}, [debouncedSearchText, filterStatus, filterCategoryId, filterModelId, filterAreaId]);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
const params = new URLSearchParams();
params.set("page", String(page));
params.set("pageSize", String(ASSET_ITEMS_PAGE_SIZE));
if (filterStatus !== "all") params.set("status", filterStatus);
if (filterCategoryId !== "all") params.set("categoryId", filterCategoryId);
if (filterModelId !== "all") params.set("modelId", filterModelId);
if (filterAreaId !== "all") params.set("areaId", filterAreaId);
if (debouncedSearchText) params.set("search", debouncedSearchText);
try {
const response = await fetch(`/api/assets?${params.toString()}`);
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (cancelled) return;
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";
setError(message);
setAssetItems([]);
setAssetItemsTotal(0);
return;
}
const parsed = body as { items?: AssetItemRecord[]; total?: number };
setAssetItems(Array.isArray(parsed.items) ? parsed.items : []);
setAssetItemsTotal(typeof parsed.total === "number" ? parsed.total : 0);
} catch {
if (!cancelled) {
setError("Request failed");
setAssetItems([]);
setAssetItemsTotal(0);
}
} finally {
if (!cancelled) setLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
}, [page, filterStatus, filterCategoryId, filterModelId, filterAreaId, debouncedSearchText, refreshKey]);
const totalPages = Math.max(1, Math.ceil(assetItemsTotal / ASSET_ITEMS_PAGE_SIZE));
// modelId -> "Manufacturer Name" -- avoids showing the raw model_id UUID
// in the table (2026-08-25 -- was previously the literal FK value).
const modelNameById = useMemo(() => {
const map = new Map<string, string>();
for (const m of assetModels) {
map.set(m.id, formatModelLabel(m.manufacturer, m.name));
}
return map;
}, [assetModels]);
const modelCategoryById = useMemo(() => {
const map = new Map<string, string>();
for (const m of assetModels) {
map.set(m.id, m.category_id);
}
return map;
}, [assetModels]);
// Model dropdown options narrow to the picked Category -- with 135
// models after the 2026-08-24 real-data import, showing all of them
// regardless of category is unwieldy.
const modelsForCategoryFilter = useMemo(() => {
if (filterCategoryId === "all") return assetModels;
return assetModels.filter((m) => m.category_id === filterCategoryId);
}, [assetModels, filterCategoryId]);
function handleFilterCategoryChange(categoryId: string) {
setFilterCategoryId(categoryId);
// A previously-picked Model may not belong to the newly-picked
// Category -- reset rather than silently keep filtering on a model
// that's no longer shown in the (now-narrowed) dropdown.
if (
filterModelId !== "all" &&
categoryId !== "all" &&
modelCategoryById.get(filterModelId) !== categoryId
) {
setFilterModelId("all");
}
}
const areaNameById = useMemo(() => {
const map = new Map<string, string>();
for (const a of areas) {
map.set(a.id, a.name);
}
return map;
}, [areas]);
const employeeNameById = useMemo(() => {
const map = new Map<string, string>();
for (const e of employees) {
map.set(e.id, e.name);
}
return map;
}, [employees]);
// assetItemId -> employee name, current holder only (returned_at IS
// NULL) -- an AssetItem can have multiple historical Assignment rows
// over time (see Assignment model's comment), only the active one
// matters for "who has this right now".
const assigneeNameByAssetItemId = useMemo(() => {
const map = new Map<string, string>();
for (const a of assignments) {
if (a.returned_at !== null || !a.asset_item_id) continue;
const name = employeeNameById.get(a.employee_id);
if (name) map.set(a.asset_item_id, name);
}
return map;
}, [assignments, employeeNameById]);
// Filtering + pagination both happen server-side now (see the effect
// above) -- `assetItems` here is already exactly the current page's
// rows for the active filters, nothing left to compute client-side.
const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">(
"idle"
);
const [importMessage, setImportMessage] = useState<string | null>(null);
async function handleImportChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = ""; // allow re-selecting the same file name after an error
if (!file) return;
setImportStatus("uploading");
setImportMessage(null);
try {
const text = await file.text();
const res = await fetch("/api/assets/import", {
method: "POST",
headers: { "Content-Type": "text/csv" },
body: text,
});
const body = await res.json();
if (!res.ok) {
setImportStatus("error");
setImportMessage(body.error ?? "Import thất bại");
return;
}
const errorCount = (body.errors as unknown[] | undefined)?.length ?? 0;
setImportStatus(errorCount > 0 ? "error" : "done");
const errorSummary =
errorCount > 0
? " Lỗi: " +
(body.errors as { row: number; serialNumber: string; error: string }[])
.slice(0, 5)
.map((e) => `dòng ${e.row} (${e.serialNumber || "?"}): ${e.error}`)
.join("; ") +
(errorCount > 5 ? ` (+${errorCount - 5} lỗi khác)` : "")
: "";
setImportMessage(`Tạo mới ${body.created}, cập nhật ${body.updated} asset.` + errorSummary);
refetch();
} catch {
setImportStatus("error");
setImportMessage("Không đọc được file -- kiểm tra lại định dạng CSV.");
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
const hasAssetTag = assetTag.trim() !== "";
const hasWarrantyEndDate = warrantyEndDate.trim() !== "";
const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE;
const hasAreaId = areaId !== NO_AREA_VALUE;
const hasCost = cost.trim() !== "";
const hasCostCurrency = costCurrency.trim() !== "";
const hasExternalPoNumber = externalPoNumber.trim() !== "";
if (hasCost && Number.isNaN(Number(cost))) {
setSubmitError("Cost must be a number");
setSubmitting(false);
return;
}
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).
...(hasAssetTag ? { assetTag } : {}),
...(hasWarrantyEndDate ? { warrantyEndDate } : {}),
...(hasPurchaseOrderId ? { purchaseOrderId } : {}),
...(hasAreaId ? { areaId } : {}),
...(hasCost ? { cost: Number(cost) } : {}),
...(hasCostCurrency ? { costCurrency } : {}),
...(hasExternalPoNumber ? { externalPoNumber } : {}),
}),
});
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("");
setAssetTag("");
setModelId("");
setPurchaseDate("");
setWarrantyEndDate("");
setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE);
setAreaId(NO_AREA_VALUE);
setCost("");
setCostCurrency("");
setExternalPoNumber("");
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">
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-2">
<Label htmlFor="search">Search (serial / asset tag / assigned to)</Label>
<Input
id="search"
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
placeholder="e.g. DANLAP001"
className="w-64"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="filterStatus">Status</Label>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger id="filterStatus" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="in_stock">in_stock</SelectItem>
<SelectItem value="in_use">in_use</SelectItem>
<SelectItem value="repair">repair</SelectItem>
<SelectItem value="retired">retired</SelectItem>
<SelectItem value="lost">lost</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="filterCategory">Category</Label>
<Select value={filterCategoryId} onValueChange={handleFilterCategoryChange}>
<SelectTrigger id="filterCategory" className="w-52">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
{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="filterModel">Model</Label>
<Select value={filterModelId} onValueChange={setFilterModelId}>
<SelectTrigger id="filterModel" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
{modelsForCategoryFilter.map((assetModel) => (
<SelectItem key={assetModel.id} value={assetModel.id}>
{formatModelLabel(assetModel.manufacturer, assetModel.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="filterArea">Area</Label>
<Select value={filterAreaId} onValueChange={setFilterAreaId}>
<SelectTrigger id="filterArea" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
{areas.map((area) => (
<SelectItem key={area.id} value={area.id}>
{area.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="pb-2 text-sm text-muted-foreground">
{assetItemsTotal === 0
? "0 assets"
: `${(page - 1) * ASSET_ITEMS_PAGE_SIZE + 1}-${Math.min(
page * ASSET_ITEMS_PAGE_SIZE,
assetItemsTotal
)} / ${assetItemsTotal} assets`}
</p>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Serial Number</TableHead>
<TableHead>Asset Tag</TableHead>
<TableHead>Model</TableHead>
<TableHead>Area</TableHead>
<TableHead>Assigned To</TableHead>
<TableHead>Purchase Date</TableHead>
<TableHead>Warranty End Date</TableHead>
<TableHead>Status</TableHead>
<TableHead>Cost</TableHead>
<TableHead>Purchase Order ID</TableHead>
<TableHead>External PO Number</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assetItems.map((assetItem, index) => (
<TableRow key={assetItem.id}>
<TableCell>{(page - 1) * ASSET_ITEMS_PAGE_SIZE + index + 1}</TableCell>
<TableCell>{assetItem.serial_number}</TableCell>
<TableCell>{assetItem.asset_tag ?? "-"}</TableCell>
<TableCell>{modelNameById.get(assetItem.model_id) ?? assetItem.model_id}</TableCell>
<TableCell>
{assetItem.area_id ? (areaNameById.get(assetItem.area_id) ?? "-") : "-"}
</TableCell>
<TableCell>{assigneeNameByAssetItemId.get(assetItem.id) ?? "-"}</TableCell>
<TableCell>{formatDateMMDDYYYY(assetItem.purchase_date)}</TableCell>
<TableCell>{formatDateMMDDYYYY(assetItem.warranty_end_date)}</TableCell>
<TableCell>{assetItem.status}</TableCell>
<TableCell>
{assetItem.cost !== null
? `${assetItem.cost}${assetItem.cost_currency ? " " + assetItem.cost_currency : ""}`
: "-"}
</TableCell>
<TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell>
<TableCell>{assetItem.external_po_number ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{!loading && !error && assetItemsTotal > 0 ? (
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<p className="text-sm text-muted-foreground">
Page {page} / {totalPages}
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
</Button>
</div>
) : null}
</section>
<section className="flex max-w-2xl flex-col gap-3">
<h2 className="text-lg font-semibold">Import / Export CSV</h2>
<p className="text-sm text-muted-foreground">
Cột nhận diện theo tên (không phân biệt hoa/thường): <code>serial number</code>,{" "}
<code>manufacturer</code>, <code>model</code> (phải khớp Asset Model đã có sẵn ở{" "}
<a href="/asset-models" className="underline">
/asset-models
</a>
), <code>purchase date</code> (không bắt buộc -- cũng nhận <code>order received</code>).
Tuỳ chọn: <code>asset tag</code>, <code>warranty end date</code> (bỏ trống thì tự tính
từ warranty của model), <code>status</code>/<code>state</code> (nhận{" "}
<code>In use</code> → in_use, <code>In stock</code> → in_stock, bỏ trống thì mặc định
in_stock), <code>cost</code>, <code>cost currency</code>,{" "}
<code>po number</code> (text tự do, khác <code>purchase order id</code> là UUID trong
hệ thống), <code>area</code> (tên khu vực -- tự tạo mới nếu chưa có, cũng nhận{" "}
<code>stockroom</code>). Dòng có <code>asset tag</code> hoặc <code>serial number</code>{" "}
đã tồn tại sẽ <b>cập nhật</b> thay vì báo lỗi trùng -- import lại file mới để đồng bộ dữ
liệu.
</p>
<div className="flex flex-wrap items-center gap-3">
<a href="/api/assets/export">
<Button type="button" variant="outline">
Export CSV
</Button>
</a>
<input
type="file"
accept=".csv,text/csv"
onChange={handleImportChange}
disabled={importStatus === "uploading"}
className="hidden"
id="assets-csv-input"
/>
<Button asChild>
<label htmlFor="assets-csv-input" className="cursor-pointer">
{importStatus === "uploading" ? "Đang import..." : "Import CSV"}
</label>
</Button>
</div>
{importMessage ? (
<p
className={
importStatus === "error" ? "text-sm text-destructive" : "text-sm text-foreground"
}
>
{importMessage}
</p>
) : null}
</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="assetTag">Asset Tag (optional)</Label>
<Input
id="assetTag"
value={assetTag}
onChange={(event) => setAssetTag(event.target.value)}
/>
</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}>
{formatModelLabel(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>
<div className="flex flex-col gap-2">
<Label htmlFor="areaId">Area (optional)</Label>
<Select value={areaId} onValueChange={setAreaId}>
<SelectTrigger id="areaId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_AREA_VALUE}>None</SelectItem>
{areas.map((area) => (
<SelectItem key={area.id} value={area.id}>
{area.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="cost">Cost (optional)</Label>
<Input
id="cost"
type="number"
min={0}
step="0.01"
value={cost}
onChange={(event) => setCost(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="costCurrency">Cost Currency (optional)</Label>
<Input
id="costCurrency"
value={costCurrency}
onChange={(event) => setCostCurrency(event.target.value)}
placeholder="e.g. VND, USD"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="externalPoNumber">External PO Number (optional)</Label>
<Input
id="externalPoNumber"
value={externalPoNumber}
onChange={(event) => setExternalPoNumber(event.target.value)}
placeholder="PO number from an external system, if any"
/>
</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>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
// Pre-move CSV upload -- see src/lib/expected-assets.ts's
// parseExpectedAssetsCsv() for the accepted column names and
// prisma/schema.prisma's ExpectedAsset comment for the "why" (2026-08-21
// decision: OCR wasn't reliable/fast enough for ~300-400 assets, but
// re-typing a list that already exists in Excel is still wasted effort --
// this feeds the bot's "🔍 Tìm trong list" picker instead). Plain
// client-side File.text() + raw-text POST, no multipart/FormData -- these
// files are small (hundreds of rows) and this repo has no existing
// multipart-upload plumbing to reuse.
export default function ImportExpectedAssetsPage() {
const [status, setStatus] = useState<"idle" | "uploading" | "done" | "error">("idle");
const [message, setMessage] = useState<string | null>(null);
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = ""; // allow re-selecting the same file name after an error
if (!file) return;
setStatus("uploading");
setMessage(null);
try {
const text = await file.text();
const res = await fetch("/api/expected-assets/import", {
method: "POST",
headers: { "Content-Type": "text/csv" },
body: text,
});
const body = await res.json();
if (!res.ok) {
setStatus("error");
setMessage(body.error ?? "Import thất bại");
return;
}
setStatus("done");
setMessage(`Đã import ${body.imported} dòng.`);
} catch {
setStatus("error");
setMessage("Không đọc được file -- kiểm tra lại định dạng CSV.");
}
}
return (
<main className="mx-auto max-w-2xl space-y-4 p-6">
<h1 className="text-xl font-semibold">Import danh sách asset đã có (CSV)</h1>
<p className="text-sm text-muted-foreground">
Upload file CSV liệt kê asset đã biết từ trước (asset tag, model, khu vực/người giữ cũ).
Trong lúc đi kiểm kê, bot Telegram sẽ có nút &quot;🔍 Tìm trong list&quot; để chọn từ đây
thay vì gõ tay, và tự gợi ý khu vực/người giữ cũ.
</p>
<p className="text-sm text-muted-foreground">
Cột nhận diện theo tên (không phân biệt hoa/thường, thứ tự tuỳ ý): <code>asset tag</code>,{" "}
<code>model</code>, <code>serial</code>, <code>area</code> (khu vực cũ),{" "}
<code>assignee</code> (người giữ cũ). Dòng đầu tiên phải là header.
</p>
<div>
<input
type="file"
accept=".csv,text/csv"
onChange={handleFileChange}
disabled={status === "uploading"}
className="hidden"
id="csv-input"
/>
<Button asChild>
<label htmlFor="csv-input" className="cursor-pointer">
{status === "uploading" ? "Đang import..." : "Chọn file CSV"}
</label>
</Button>
</div>
{message ? (
<p className={status === "error" ? "text-sm text-destructive" : "text-sm text-foreground"}>
{message}
</p>
) : null}
</main>
);
}
+347
View File
@@ -0,0 +1,347 @@
"use client";
import { useState } from "react";
import { useApiList } from "@/hooks/use-api-list";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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/inventory-scans — snake_case per this repo's
// API-boundary convention (see src/lib/inventory-scans.ts's toRecord()).
interface InventoryScanRecord {
id: string;
category_id: string;
category_name: string;
model_id: string;
model_name: string;
model_manufacturer: string;
area_id: string | null;
area_name: string | null;
assigned_employee_id: string | null;
assigned_employee_name: string | null;
asset_tag: string | null;
serial_number: string | null;
specs: Record<string, unknown>;
photo_file_id: string | null;
created_at: string;
}
interface AreaRecord {
id: string;
name: string;
team: string | null;
}
interface EmployeeRecord {
id: string;
name: string;
}
interface AssetModelRecord {
id: string;
category_id: string;
manufacturer: string;
name: string;
}
const NO_AREA_VALUE = "none";
const NO_EMPLOYEE_VALUE = "none";
export default function InventoryScansPage() {
const { data: scans, loading, error, refetch } = useApiList<InventoryScanRecord>(
"/api/inventory-scans"
);
const { data: areas } = useApiList<AreaRecord>("/api/areas");
const { data: employees } = useApiList<EmployeeRecord>("/api/employees");
const { data: models } = useApiList<AssetModelRecord>("/api/asset-models");
const [editingId, setEditingId] = useState<string | null>(null);
const [editModelId, setEditModelId] = useState("");
const [editAreaId, setEditAreaId] = useState(NO_AREA_VALUE);
const [editEmployeeId, setEditEmployeeId] = useState(NO_EMPLOYEE_VALUE);
const [editAssetTag, setEditAssetTag] = useState("");
const [editSerialNumber, setEditSerialNumber] = useState("");
const [editSpecs, setEditSpecs] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [rowError, setRowError] = useState<string | null>(null);
function startEdit(scan: InventoryScanRecord) {
setEditingId(scan.id);
setEditModelId(scan.model_id);
setEditAreaId(scan.area_id ?? NO_AREA_VALUE);
setEditEmployeeId(scan.assigned_employee_id ?? NO_EMPLOYEE_VALUE);
setEditAssetTag(scan.asset_tag ?? "");
setEditSerialNumber(scan.serial_number ?? "");
setEditSpecs(
Object.fromEntries(Object.entries(scan.specs).map(([k, v]) => [k, String(v ?? "")]))
);
setRowError(null);
}
function cancelEdit() {
setEditingId(null);
setRowError(null);
}
async function saveEdit(scan: InventoryScanRecord) {
setSaving(true);
setRowError(null);
try {
const specs: Record<string, unknown> = {};
for (const [k, v] of Object.entries(editSpecs)) {
const asNumber = Number(v);
specs[k] = v !== "" && !Number.isNaN(asNumber) && /^-?\d+(\.\d+)?$/.test(v) ? asNumber : v;
}
const response = await fetch(`/api/inventory-scans/${scan.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
modelId: editModelId,
areaId: editAreaId === NO_AREA_VALUE ? null : editAreaId,
assignedEmployeeId: editEmployeeId === NO_EMPLOYEE_VALUE ? null : editEmployeeId,
assetTag: editAssetTag.trim() === "" ? null : editAssetTag.trim(),
serialNumber: editSerialNumber.trim() === "" ? null : editSerialNumber.trim(),
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
? String((body as { error: unknown }).error)
: "Request failed";
setRowError(message);
return;
}
setEditingId(null);
refetch();
} catch {
setRowError("Request failed");
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
if (!confirm("Xoá bản ghi này?")) return;
const response = await fetch(`/api/inventory-scans/${id}`, { method: "DELETE" });
if (response.ok) {
refetch();
}
}
return (
<div className="flex flex-col gap-6 p-6">
<h1 className="text-xl font-semibold">Inventory Scans (Move Project)</h1>
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Ảnh</TableHead>
<TableHead>Category</TableHead>
<TableHead>Model</TableHead>
<TableHead>Asset Tag</TableHead>
<TableHead>Serial Number</TableHead>
<TableHead>Specs</TableHead>
<TableHead>Area</TableHead>
<TableHead>Assigned to</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{scans.map((scan) => {
const isEditing = editingId === scan.id;
const modelsInCategory = models.filter((m) => m.category_id === scan.category_id);
return (
<TableRow key={scan.id}>
<TableCell>
{scan.photo_file_id ? (
<a
href={`/api/inventory-scans/${scan.id}/photo`}
target="_blank"
rel="noreferrer"
className="text-blue-500 underline"
>
Xem ảnh
</a>
) : (
"-"
)}
</TableCell>
<TableCell>{scan.category_name}</TableCell>
<TableCell>
{isEditing ? (
<Select value={editModelId} onValueChange={setEditModelId}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{modelsInCategory.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.manufacturer} {m.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
`${scan.model_manufacturer} ${scan.model_name}`
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
className="h-8 w-28"
value={editAssetTag}
onChange={(e) => setEditAssetTag(e.target.value)}
placeholder="(chưa có)"
/>
) : (
scan.asset_tag ?? "-"
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
className="h-8 w-32"
value={editSerialNumber}
onChange={(e) => setEditSerialNumber(e.target.value)}
placeholder="(chưa có)"
/>
) : (
scan.serial_number ?? "-"
)}
</TableCell>
<TableCell>
{isEditing ? (
<div className="flex flex-col gap-1">
{Object.keys(editSpecs).length === 0 ? (
<span className="text-muted-foreground text-xs">(không có)</span>
) : (
Object.entries(editSpecs).map(([key, value]) => (
<div key={key} className="flex items-center gap-1">
<span className="w-16 text-xs text-muted-foreground">{key}</span>
<Input
className="h-7"
value={value}
onChange={(e) =>
setEditSpecs((prev) => ({ ...prev, [key]: e.target.value }))
}
/>
</div>
))
)}
</div>
) : Object.keys(scan.specs).length === 0 ? (
"-"
) : (
Object.entries(scan.specs)
.map(([k, v]) => `${k}: ${v}`)
.join(", ")
)}
</TableCell>
<TableCell>
{isEditing ? (
<Select value={editAreaId} onValueChange={setEditAreaId}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_AREA_VALUE}>None</SelectItem>
{areas.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
scan.area_name ?? "-"
)}
</TableCell>
<TableCell>
{isEditing ? (
<Select value={editEmployeeId} onValueChange={setEditEmployeeId}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_EMPLOYEE_VALUE}>None</SelectItem>
{employees.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
scan.assigned_employee_name ?? "-"
)}
</TableCell>
<TableCell>{scan.created_at}</TableCell>
<TableCell>
{isEditing ? (
<div className="flex flex-col gap-1">
<div className="flex gap-1">
<Button size="sm" disabled={saving} onClick={() => saveEdit(scan)}>
Save
</Button>
<Button size="sm" variant="outline" onClick={cancelEdit}>
Cancel
</Button>
</div>
{rowError ? <p className="text-xs text-destructive">{rowError}</p> : null}
</div>
) : (
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={() => startEdit(scan)}>
Edit
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(scan.id)}
>
Delete
</Button>
</div>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
);
}
+14 -2
View File
@@ -1,15 +1,27 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import "./globals.css"; import "./globals.css";
import { auth } from "@/auth";
import { Nav } from "@/components/nav";
export const metadata = { export const metadata = {
title: "Asset & License Management", title: "Asset & License Management",
description: "Internal IT asset & license management — Ubisoft VN local IT", 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 ( return (
<html lang="en"> <html lang="en">
<body>{children}</body> <body>
{session ? <Nav /> : null}
{children}
</body>
</html> </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", "in_use", "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>
);
}
+58
View File
@@ -0,0 +1,58 @@
"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" },
{ href: "/inventory-scans", label: "Inventory Scans (Move)" },
{ href: "/inventory-scans/import", label: "Import Expected Assets" },
] 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 };
}
+92
View File
@@ -0,0 +1,92 @@
// Area — physical location tag for the office-move inventory walk (see
// prisma/schema.prisma's Area model comment). Same shape/conventions as
// src/lib/employees.ts: validate -> create (P2002 on duplicate name) ->
// list.
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
export interface AreaRecord {
id: string;
name: string;
team: string | null;
created_at: string;
}
export interface CreateAreaInput {
name: string;
team?: string;
}
export type ValidationResult =
| { ok: true; value: CreateAreaInput }
| { ok: false; error: string };
function nonEmptyString(message: string) {
return z.string({ required_error: message, invalid_type_error: message }).refine(
(value) => value.trim() !== "",
{ message }
);
}
const createAreaSchema = z.object({
name: nonEmptyString("name is required and must be a non-empty string"),
team: z.string({ invalid_type_error: "team must be a string" }).optional(),
});
export function validateCreateAreaInput(input: {
name?: unknown;
team?: unknown;
}): ValidationResult {
const result = createAreaSchema.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;
team: string | null;
createdAt: Date;
}): AreaRecord {
return {
id: row.id,
name: row.name,
team: row.team,
created_at: row.createdAt.toISOString(),
};
}
export type CreateAreaResult = { ok: true; value: AreaRecord } | { ok: false; error: string };
/**
* Creates an Area, or returns the existing one if `name` already exists
* (idempotent-by-name) — this is deliberate, not just P2002 error handling:
* the bot's "reuse existing or type a new one" flow calls this every time a
* user picks/types an Area name, and re-submitting an Area the bot already
* showed as an existing option must succeed, not 409.
*/
export async function createOrGetArea(input: CreateAreaInput): Promise<CreateAreaResult> {
try {
const created = await prisma.area.create({
data: { name: input.name, team: input.team ?? null },
});
return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
const existing = await prisma.area.findUnique({ where: { name: input.name } });
if (existing) {
return { ok: true, value: toRecord(existing) };
}
}
throw error;
}
}
export async function listAreas(): Promise<AreaRecord[]> {
const rows = await prisma.area.findMany({ orderBy: [{ name: "asc" }] });
return rows.map(toRecord);
}
+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);
}
+228
View File
@@ -0,0 +1,228 @@
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>;
warranty_months: number | null;
created_at: string;
}
export interface CreateAssetModelInput {
categoryId: string;
manufacturer: string;
name: string;
specs: Record<string, unknown>;
warrantyMonths?: number | null;
}
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({}),
// Optional standard warranty length -- see schema.prisma's
// AssetModel.warrantyMonths comment for what this drives.
warrantyMonths: z
.number({ invalid_type_error: "warrantyMonths must be a positive integer" })
.int("warrantyMonths must be a positive integer")
.positive("warrantyMonths must be a positive integer")
.optional()
.nullable(),
});
/**
* 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;
warrantyMonths?: 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;
warrantyMonths: number | null;
createdAt: Date;
}): AssetModelRecord {
return {
id: row.id,
category_id: row.categoryId,
manufacturer: row.manufacturer,
name: row.name,
specs: (row.specs as Record<string, unknown>) ?? {},
warranty_months: row.warrantyMonths,
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,
warrantyMonths: input.warrantyMonths ?? null,
},
});
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);
}
+679 -24
View File
@@ -1,24 +1,55 @@
import { z } from "zod"; import { z } from "zod";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { splitCsvLine, splitCsvLines, normalizeHeader, csvField } from "./csv";
import { createOrGetArea } from "./areas";
export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired"; export type AssetItemStatus = "in_stock" | "in_use" | "repair" | "retired" | "lost";
export interface AssetItemRecord { export interface AssetItemRecord {
id: string; id: string;
serial_number: string; serial_number: string;
model: string; asset_tag: string | null;
purchase_date: string; model_id: string;
purchase_date: string | null;
warranty_end_date: string | null; warranty_end_date: string | null;
status: AssetItemStatus; status: AssetItemStatus;
cost: number | null;
cost_currency: string | null;
area_id: string | null;
purchase_order_id: string | null;
// Free-text PO number from an external system -- NOT purchase_order_id
// above (a real FK). See schema.prisma's AssetItem.externalPoNumber
// comment.
external_po_number: string | null;
created_at: string; created_at: string;
} }
export interface CreateAssetItemInput { export interface CreateAssetItemInput {
serialNumber: string; serialNumber: string;
model: string; assetTag?: string | null;
purchaseDate: Date; modelId: string;
// Optional as of 2026-08-24 (was required) -- some bulk-imported legacy
// rows have no known purchase date at all. The manual /assets create
// form still marks its date input required (a NEW purchase should
// always have a known date); this only relaxes the underlying type for
// historical/imported data. See schema.prisma's AssetItem.purchaseDate
// comment.
purchaseDate?: Date;
warrantyEndDate?: Date; warrantyEndDate?: Date;
cost?: number | null;
costCurrency?: string | null;
areaId?: string | null;
purchaseOrderId?: string | null;
externalPoNumber?: string | null;
// Only set by importAssetItems() (bulk-loading already-deployed
// inventory from a real export -- see docs/asset-license-management.md's
// 2026-08-24 section). The manual /assets create form never sends this,
// so a plain createAssetItem() call keeps defaulting to "in_stock" same
// as before this field existed -- see validateCreateAssetItemInput's
// comment for why that stays the rule for *new* purchases entered one at
// a time.
status?: AssetItemStatus;
} }
export type ValidationResult = export type ValidationResult =
@@ -45,24 +76,78 @@ function dateString(message: string) {
const createAssetItemSchema = z.object({ const createAssetItemSchema = z.object({
serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"), serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"),
model: nonEmptyString("model is required and must be a non-empty string"), assetTag: z
purchaseDate: dateString("purchaseDate is required and must be a valid date string"), .string({ invalid_type_error: "assetTag must be a string" })
.optional()
.nullable(),
// 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 must be a valid date string").optional(),
warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(), 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(),
cost: z
.number({ invalid_type_error: "cost must be a non-negative number" })
.nonnegative("cost must be a non-negative number")
.optional()
.nullable(),
costCurrency: z
.string({ invalid_type_error: "costCurrency must be a string" })
.optional()
.nullable(),
externalPoNumber: z
.string({ invalid_type_error: "externalPoNumber must be a string" })
.optional()
.nullable(),
areaId: z
.string({ invalid_type_error: "areaId must be a valid UUID string" })
.uuid("areaId must be a valid UUID string")
.optional()
.nullable(),
}); });
/** /**
* Validates raw (untyped) asset-item-creation input. serialNumber/model * Validates raw (untyped) asset-item-creation input. serialNumber must be a
* must be non-empty strings, purchaseDate must be a valid date string, * non-empty string, assetTag is optional/nullable free text (no format
* warrantyEndDate is optional but must be a valid date string if present. * check -- company tag schemes vary), modelId must be a valid UUID string,
* status is not accepted here — new assets always start "in_stock" (same * purchaseDate must be a valid date string, warrantyEndDate is optional
* pattern as Request always starting "Draft"), later states are set via * but must be a valid date string if present. purchaseOrderId is
* other operations, not at creation. * optional/nullable but must be a valid UUID if present. status is not
* accepted here — new assets entered one at a time via the manual /assets
* form always start "in_stock" (same pattern as Request always starting
* "Draft"). importAssetItems() below is the one exception: it calls
* createAssetItem() directly (bypassing this validator) with a real status
* resolved from the import file, because it's loading already-deployed
* inventory, not new purchases -- see docs/asset-license-management.md's
* 2026-08-24 section.
*/ */
export function validateCreateAssetItemInput(input: { export function validateCreateAssetItemInput(input: {
serialNumber?: unknown; serialNumber?: unknown;
model?: unknown; assetTag?: unknown;
modelId?: unknown;
purchaseDate?: unknown; purchaseDate?: unknown;
warrantyEndDate?: unknown; warrantyEndDate?: unknown;
purchaseOrderId?: unknown;
cost?: unknown;
costCurrency?: unknown;
externalPoNumber?: unknown;
areaId?: unknown;
}): ValidationResult { }): ValidationResult {
const result = createAssetItemSchema.safeParse(input); const result = createAssetItemSchema.safeParse(input);
if (!result.success) { if (!result.success) {
@@ -75,19 +160,31 @@ export function validateCreateAssetItemInput(input: {
function toRecord(row: { function toRecord(row: {
id: string; id: string;
serialNumber: string; serialNumber: string;
model: string; assetTag: string | null;
purchaseDate: Date; modelId: string;
purchaseDate: Date | null;
warrantyEndDate: Date | null; warrantyEndDate: Date | null;
status: AssetItemStatus; status: AssetItemStatus;
cost: Prisma.Decimal | null;
costCurrency: string | null;
areaId: string | null;
purchaseOrderId: string | null;
externalPoNumber: string | null;
createdAt: Date; createdAt: Date;
}): AssetItemRecord { }): AssetItemRecord {
return { return {
id: row.id, id: row.id,
serial_number: row.serialNumber, serial_number: row.serialNumber,
model: row.model, asset_tag: row.assetTag,
purchase_date: row.purchaseDate.toISOString(), model_id: row.modelId,
purchase_date: row.purchaseDate ? row.purchaseDate.toISOString() : null,
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null, warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
status: row.status, status: row.status,
cost: row.cost ? row.cost.toNumber() : null,
cost_currency: row.costCurrency,
area_id: row.areaId,
purchase_order_id: row.purchaseOrderId,
external_po_number: row.externalPoNumber,
created_at: row.createdAt.toISOString(), created_at: row.createdAt.toISOString(),
}; };
} }
@@ -102,32 +199,590 @@ export type CreateAssetItemResult =
* caught here and translated into the same `{ok:false,error}` shape as * caught here and translated into the same `{ok:false,error}` shape as
* input validation failures, rather than letting a raw Prisma error leak * input validation failures, rather than letting a raw Prisma error leak
* to the API layer. Mirrors createEmployee()/createRequest()'s P2003 * 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.
*/ */
// Adds `months` calendar months to `date`, clamping to the last day of the
// target month rather than letting JS's native Date rollover push into the
// month after (e.g. Jan 31 + 1 month must land on Feb 28/29, not Mar
// 2/3 -- `date.setMonth()` alone does the latter). UTC throughout: Prisma
// @db.Date fields round-trip as UTC-midnight Dates (see dateString()
// above), and doing this in UTC avoids the host server's local timezone
// shifting the day near midnight.
function addMonthsClamped(date: Date, months: number): Date {
const targetMonthIndex = date.getUTCMonth() + months;
const daysInTargetMonth = new Date(
Date.UTC(date.getUTCFullYear(), targetMonthIndex + 1, 0)
).getUTCDate();
return new Date(
Date.UTC(
date.getUTCFullYear(),
targetMonthIndex,
Math.min(date.getUTCDate(), daysInTargetMonth)
)
);
}
export async function createAssetItem( export async function createAssetItem(
input: CreateAssetItemInput input: CreateAssetItemInput
): Promise<CreateAssetItemResult> { ): Promise<CreateAssetItemResult> {
try { try {
// warrantyEndDate defaults from the model's standard warranty term
// (AssetModel.warrantyMonths) when the caller doesn't supply one
// explicitly -- see schema.prisma's AssetModel.warrantyMonths comment
// for why this stays a suggestion (still overridable per item) rather
// than a hard-computed column. A modelId that doesn't exist is left for
// the insert below's own P2003 handling, same as before this lookup was
// added -- this just also opportunistically uses the model row if it's
// there.
let warrantyEndDate = input.warrantyEndDate ?? null;
if (!warrantyEndDate && input.purchaseDate) {
const model = await prisma.assetModel.findUnique({ where: { id: input.modelId } });
if (model?.warrantyMonths) {
warrantyEndDate = addMonthsClamped(input.purchaseDate, model.warrantyMonths);
}
}
const created = await prisma.assetItem.create({ const created = await prisma.assetItem.create({
data: { data: {
serialNumber: input.serialNumber, serialNumber: input.serialNumber,
model: input.model, assetTag: input.assetTag ?? null,
purchaseDate: input.purchaseDate, modelId: input.modelId,
warrantyEndDate: input.warrantyEndDate ?? null, purchaseDate: input.purchaseDate ?? null,
warrantyEndDate,
cost: input.cost ?? null,
costCurrency: input.costCurrency ?? null,
areaId: input.areaId ?? null,
purchaseOrderId: input.purchaseOrderId ?? null,
externalPoNumber: input.externalPoNumber ?? null,
...(input.status ? { status: input.status } : {}),
}, },
}); });
return { ok: true, value: toRecord(created) }; return { ok: true, value: toRecord(created) };
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
// serialNumber and assetTag are both unique now -- error.meta.target
// says which constraint actually fired.
const target = Array.isArray(error.meta?.target) ? error.meta.target.join(",") : "";
if (target.includes("asset_tag")) {
return { ok: false, error: "assetTag already exists" };
}
return { ok: false, error: "serialNumber already exists" }; return { ok: false, error: "serialNumber already exists" };
} }
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
// Three FKs can fail here now (modelId, purchaseOrderId, areaId) --
// field_name says which constraint actually fired.
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" };
}
if (fieldName.includes("area")) {
return { ok: false, error: "areaId does not reference an existing area" };
}
return { ok: false, error: "modelId does not reference an existing asset model" };
}
throw error; throw error;
} }
} }
export async function listAssetItems(): Promise<AssetItemRecord[]> { export interface ListAssetItemsOptions {
page?: number;
pageSize?: number;
status?: AssetItemStatus;
modelId?: string;
categoryId?: string;
areaId?: string;
// Matches serialNumber, assetTag, or the name of the AssetItem's current
// (non-returned) assignee -- via the assignments relation, not a
// separate query, so this stays one round trip.
search?: string;
}
export interface ListAssetItemsResult {
items: AssetItemRecord[];
total: number;
}
const DEFAULT_PAGE_SIZE = 50;
/**
* Lists AssetItems with server-side filtering + pagination (2026-08-25 --
* was a bare unpaginated findMany(); at ~600+ rows post the 2026-08-24
* real-data import, shipping the whole table to the client on every page
* load was wasteful, and /assets's client-side filtering couldn't scale
* past "the whole table is already in memory" anyway). Filtering and
* pagination both happen in the same WHERE clause -- `total` reflects the
* FILTERED count, not the table's overall size, so pagination controls
* built from it are correct regardless of which filters are active.
*/
export async function listAssetItems(
options: ListAssetItemsOptions = {}
): Promise<ListAssetItemsResult> {
const page = options.page && options.page > 0 ? Math.floor(options.page) : 1;
const pageSize =
options.pageSize && options.pageSize > 0 ? Math.floor(options.pageSize) : DEFAULT_PAGE_SIZE;
const where: Prisma.AssetItemWhereInput = {};
if (options.status) where.status = options.status;
if (options.modelId) where.modelId = options.modelId;
if (options.categoryId) where.model = { categoryId: options.categoryId };
if (options.areaId) where.areaId = options.areaId;
if (options.search && options.search.trim() !== "") {
const q = options.search.trim();
where.OR = [
{ serialNumber: { contains: q, mode: "insensitive" } },
{ assetTag: { contains: q, mode: "insensitive" } },
{
assignments: {
some: { returnedAt: null, employee: { name: { contains: q, mode: "insensitive" } } },
},
},
];
}
const [rows, total] = await Promise.all([
prisma.assetItem.findMany({
where,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.assetItem.count({ where }),
]);
return { items: rows.map(toRecord), total };
}
// ---------------------------------------------------------------------
// CSV import/export (2026-08-23) -- bulk-load a spreadsheet of existing
// assets, and get the current AssetItem table back out as one. Import
// resolves modelId from human-readable manufacturer+model text (matching
// src/lib/expected-assets.ts's precedent of not requiring raw UUIDs in a
// hand-edited spreadsheet) rather than accepting modelId directly --
// export emits the same manufacturer+model columns so a round-trip
// export -> edit -> re-import works without any UUID lookup by hand.
// ---------------------------------------------------------------------
export interface ImportAssetItemRow {
serialNumber: string;
assetTag: string | null;
manufacturer: string;
modelName: string;
purchaseDate: string;
warrantyEndDate: string | null;
purchaseOrderId: string | null;
// Raw text from the CSV's status-ish column (e.g. a real ServiceNow
// export's "State": "In use"/"In stock") -- translated to a real
// AssetItemStatus by statusFromImportText() in importAssetItems() below,
// not here (parsing stays dumb string-in/string-out, same as every other
// field on this row).
status: string | null;
// Raw text -- parsed to a number by importAssetItems() below (also
// treats common placeholder junk like "Not available"/"Pending"/"N/A" as
// absent, same spirit as externalPoNumber below -- real exports are
// messy).
cost: string | null;
costCurrency: string | null;
// NOT the same as purchaseOrderId above -- see schema.prisma's
// AssetItem.externalPoNumber comment.
externalPoNumber: string | null;
// Area name, resolved/created via src/lib/areas.ts's createOrGetArea()
// (idempotent by name, same as the bot's Area picker) -- not a raw UUID
// column like the other FK-ish fields here, since a hand-edited
// spreadsheet realistically has area names, not this app's internal ids.
areaName: string | null;
}
const ASSET_ITEM_HEADER_ALIASES: Record<keyof ImportAssetItemRow, string[]> = {
serialNumber: ["serialnumber", "serial number", "serial", "sn"],
assetTag: ["assettag", "asset tag", "tag", "matag"],
manufacturer: ["manufacturer", "brand", "hang", "hang san xuat"],
modelName: ["model", "modelname", "model name", "ten model"],
purchaseDate: [
"purchasedate",
"purchase date",
"ngaymua",
"ngay mua",
// "Order received" is the closest proxy for purchase date in a
// ServiceNow-style export -- there's no cleaner "date we bought this"
// field there (see docs/asset-license-management.md's 2026-08-24
// section for why "Assigned Date" is NOT used here -- that's when it
// was handed to whoever has it now, a different date entirely).
"order received",
],
warrantyEndDate: [
"warrantyenddate",
"warranty end date",
"warranty",
"baohanh",
"bao hanh",
"warranty expiration",
],
purchaseOrderId: ["purchaseorderid", "purchase order id", "po", "poid", "po id"],
status: ["status", "state", "tinh trang"],
cost: ["cost", "gia", "gia tri"],
costCurrency: ["costcurrency", "cost currency", "currency", "don vi tien te"],
// "po number" is deliberately distinct from purchaseOrderId's aliases
// above ("po"/"poid"/"po id") -- matches a real ServiceNow export's
// actual "PO number" column name 1:1, and keeps the two fields
// unambiguous from each other by header name alone.
externalPoNumber: ["ponumber", "po number", "external po number", "so po"],
areaName: ["area", "khu vuc", "location", "stockroom", "vi tri"],
};
export type ParseAssetItemsCsvResult =
| { ok: true; rows: ImportAssetItemRow[] }
| { ok: false; error: string };
/**
* Parses a CSV export into rows ready for importAssetItems(). Unlike
* src/lib/expected-assets.ts's parser (a staging/lookup table with no hard
* NOT NULL requirements), the fields an AssetItem actually needs at
* creation -- serialNumber/manufacturer/modelName (purchaseDate is
* optional as of 2026-08-24, see schema.prisma's AssetItem.purchaseDate
* comment) -- must all be present as recognized header columns, or this
* fails fast on the whole file rather than silently producing rows
* importAssetItems() can never create. Individual rows with one of those
* required fields blank still aren't rejected here though -- that's
* importAssetItems()'s per-row error, so a
* typo in row 200 doesn't block rows 1-199.
*/
export function parseAssetItemsCsv(text: string): ParseAssetItemsCsvResult {
const lines = splitCsvLines(text);
if (lines.length === 0) {
return { ok: false, error: "CSV file is empty" };
}
const headerCells = splitCsvLine(lines[0]).map(normalizeHeader);
const columnIndex: Partial<Record<keyof ImportAssetItemRow, number>> = {};
for (const [field, aliases] of Object.entries(ASSET_ITEM_HEADER_ALIASES) as [
keyof ImportAssetItemRow,
string[],
][]) {
const idx = headerCells.findIndex((h) => aliases.includes(h));
if (idx !== -1) {
columnIndex[field] = idx;
}
}
// purchaseDate is NOT required here (as of 2026-08-24 -- see
// schema.prisma's AssetItem.purchaseDate comment): some legacy rows in
// a real bulk import genuinely have no known purchase date, and that's
// fine to import with a null purchaseDate rather than skipping the row.
const required: (keyof ImportAssetItemRow)[] = ["serialNumber", "manufacturer", "modelName"];
const missing = required.filter((field) => columnIndex[field] === undefined);
if (missing.length > 0) {
return {
ok: false,
error:
`Missing required column(s) in header: ${missing.join(", ")}. ` +
"Recognized names: " +
Object.values(ASSET_ITEM_HEADER_ALIASES).flat().join(", "),
};
}
const rows: ImportAssetItemRow[] = [];
for (const line of lines.slice(1)) {
const cells = splitCsvLine(line);
const get = (field: keyof ImportAssetItemRow): string => {
const idx = columnIndex[field];
if (idx === undefined) return "";
return (cells[idx] ?? "").trim();
};
const row: ImportAssetItemRow = {
serialNumber: get("serialNumber"),
assetTag: get("assetTag") || null,
manufacturer: get("manufacturer"),
modelName: get("modelName"),
purchaseDate: get("purchaseDate"),
warrantyEndDate: get("warrantyEndDate") || null,
purchaseOrderId: get("purchaseOrderId") || null,
status: get("status") || null,
cost: get("cost") || null,
costCurrency: get("costCurrency") || null,
externalPoNumber: get("externalPoNumber") || null,
areaName: get("areaName") || null,
};
if (!row.serialNumber && !row.manufacturer && !row.modelName && !row.purchaseDate) {
continue; // fully blank row
}
rows.push(row);
}
return { ok: true, rows };
}
export interface ImportAssetItemsResult {
created: number;
updated: number;
errors: { row: number; serialNumber: string; error: string }[];
}
// Maps free-text status values from a real export (e.g. ServiceNow's
// "State" column: "In use"/"In stock") onto this app's AssetItemStatus
// enum -- plus this app's own enum values, in case a re-exported CSV
// round-trips one back in. "In use" -> "in_use" is a simplification: it
// doesn't require (or create) a matching Assignment row, just reflects
// that the item is deployed rather than sitting in stock -- see
// docs/asset-license-management.md's 2026-08-24 section for why linking
// actual Employee assignments is explicitly out of scope for this import.
const IMPORT_STATUS_MAP: Record<string, AssetItemStatus> = {
"in use": "in_use",
in_use: "in_use",
assigned: "in_use", // backward-compat: pre-2026-08-25 exports/CSVs said "assigned"
"in stock": "in_stock",
in_stock: "in_stock",
repair: "repair",
"in repair": "repair",
retired: "retired",
disposed: "retired",
lost: "lost",
missing: "lost",
};
function statusFromImportText(text: string): AssetItemStatus | null {
return IMPORT_STATUS_MAP[text.trim().toLowerCase()] ?? null;
}
// Real external exports (e.g. the ServiceNow-style ITAM report this was
// built against) fill blank PO-number-ish cells with placeholder junk
// instead of leaving them empty -- treat these as "no value" rather than
// storing the literal junk text.
const PLACEHOLDER_VALUES = new Set(["not available", "pending", "n/a", "na", "none", "-"]);
function cleanImportText(text: string | null): string | null {
if (!text) return null;
return PLACEHOLDER_VALUES.has(text.trim().toLowerCase()) ? null : text;
}
// Cost cells can carry currency symbols/thousands separators depending on
// how the source spreadsheet formatted them (e.g. "1,234.56" or "1234").
// Strips anything that isn't a digit, minus sign, or decimal point before
// parsing -- simple on purpose, this isn't trying to be a general money
// parser, just tolerant of the punctuation Excel commonly adds.
function parseImportCost(text: string): number | null {
const cleaned = cleanImportText(text);
if (!cleaned) return null;
const numeric = Number(cleaned.replace(/[^0-9.-]/g, ""));
return Number.isNaN(numeric) ? null : numeric;
}
/**
* Creates one AssetItem per row via createAssetItem(), OR updates it if a
* row with the same assetTag or serialNumber already exists (2026-08-24 --
* added so re-uploading a refreshed export, e.g. the company's periodic
* ServiceNow re-sync, updates existing rows instead of failing every one
* of them on a uniqueness conflict). Matching prefers assetTag when
* present (the more stable real-world identifier -- a device's serial
* rarely changes but its owner/status might get re-tagged), falling back
* to serialNumber. Update only touches fields this row actually carries a
* (non-empty) value for -- a blank cell means "no change", not "clear this
* field", so partial re-syncs (e.g. a cost-only refresh with no other
* columns) don't wipe out data the file simply didn't include this time.
* warrantyEndDate is the one exception: it's only touched on UPDATE when
* explicitly present in the row (never silently recomputed from
* warrantyMonths the way CREATE does), so a manually-corrected warranty
* date survives a re-sync.
*
* Reports per-row failures instead of aborting the whole import on the
* first bad row -- matches parseAssetItemsCsv()'s "one typo shouldn't cost
* 199 other rows" rationale. `row` in the result is the spreadsheet row
* number (header = row 1), for the operator to find and fix in their
* original file. Sequential, not batched -- fine at this app's actual
* scale (hundreds of rows, see docs/asset-license-management.md), and
* keeps each row's error attributable instead of one opaque bulk failure.
*/
export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<ImportAssetItemsResult> {
const errors: ImportAssetItemsResult["errors"] = [];
let created = 0;
let updated = 0;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNumber = i + 2; // +1 for 0-index, +1 for the header row
if (!row.serialNumber || !row.manufacturer || !row.modelName) {
errors.push({
row: rowNumber,
serialNumber: row.serialNumber,
error: "Missing serialNumber/manufacturer/model",
});
continue;
}
// purchaseDate is optional (2026-08-24) -- a row with none imports
// fine with purchaseDate left null (and consequently no
// warrantyMonths auto-fill, which needs a purchase date to add
// months to). A row that HAS a purchaseDate value still has to be a
// parseable date though, same as before.
if (row.purchaseDate && Number.isNaN(Date.parse(row.purchaseDate))) {
errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: `Invalid purchaseDate: "${row.purchaseDate}"` });
continue;
}
let warrantyEndDate: Date | undefined;
if (row.warrantyEndDate) {
if (Number.isNaN(Date.parse(row.warrantyEndDate))) {
errors.push({
row: rowNumber,
serialNumber: row.serialNumber,
error: `Invalid warrantyEndDate: "${row.warrantyEndDate}"`,
});
continue;
}
warrantyEndDate = new Date(row.warrantyEndDate);
}
let status: AssetItemStatus | undefined;
if (row.status) {
const mapped = statusFromImportText(row.status);
if (!mapped) {
errors.push({
row: rowNumber,
serialNumber: row.serialNumber,
error: `Unrecognized status: "${row.status}" (expected e.g. In use, In stock, Repair, Retired)`,
});
continue;
}
status = mapped;
}
const cost = row.cost ? parseImportCost(row.cost) : null;
const costCurrency = cleanImportText(row.costCurrency);
const externalPoNumber = cleanImportText(row.externalPoNumber);
// Resolved via createOrGetArea() (idempotent by name -- same helper
// the bot's Area picker uses), not a raw areaId column: a hand-edited
// spreadsheet realistically has area *names*, and this also means a
// never-before-seen area name just gets created rather than rejecting
// the row.
let areaId: string | undefined;
const areaName = cleanImportText(row.areaName);
if (areaName) {
const areaResult = await createOrGetArea({ name: areaName });
if (!areaResult.ok) {
errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: `Area error: ${areaResult.error}` });
continue;
}
areaId = areaResult.value.id;
}
const model = await prisma.assetModel.findFirst({
where: {
manufacturer: { equals: row.manufacturer, mode: "insensitive" },
name: { equals: row.modelName, mode: "insensitive" },
},
});
if (!model) {
errors.push({
row: rowNumber,
serialNumber: row.serialNumber,
error: `No Asset Model matches "${row.manufacturer} / ${row.modelName}" -- create it on /asset-models first`,
});
continue;
}
const existing = await prisma.assetItem.findFirst({
where: {
OR: [
...(row.assetTag ? [{ assetTag: row.assetTag }] : []),
{ serialNumber: row.serialNumber },
],
},
});
if (existing) {
// Partial update -- only fields this row actually carries a value
// for (see importAssetItems()'s doc comment above for why blank
// means "no change", not "clear").
await prisma.assetItem.update({
where: { id: existing.id },
data: {
...(row.assetTag ? { assetTag: row.assetTag } : {}),
modelId: model.id,
...(row.purchaseDate ? { purchaseDate: new Date(row.purchaseDate) } : {}),
...(warrantyEndDate ? { warrantyEndDate } : {}),
...(status ? { status } : {}),
...(cost !== null ? { cost } : {}),
...(costCurrency ? { costCurrency } : {}),
...(row.purchaseOrderId ? { purchaseOrderId: row.purchaseOrderId } : {}),
...(externalPoNumber ? { externalPoNumber } : {}),
...(areaId ? { areaId } : {}),
},
});
updated++;
continue;
}
const result = await createAssetItem({
serialNumber: row.serialNumber,
assetTag: row.assetTag ?? undefined,
modelId: model.id,
purchaseDate: row.purchaseDate ? new Date(row.purchaseDate) : undefined,
warrantyEndDate,
status,
cost,
costCurrency,
purchaseOrderId: row.purchaseOrderId ?? undefined,
externalPoNumber,
areaId,
});
if (!result.ok) {
errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: result.error });
continue;
}
created++;
}
return { created, updated, errors };
}
/**
* Renders the current AssetItem table as CSV, using the same columns
* parseAssetItemsCsv() reads -- so "export, edit in Excel, re-import" is a
* real round trip, not just a one-way dump. `status` round-trips too as of
* 2026-08-24 (see IMPORT_STATUS_MAP above) -- exported as this app's own
* enum text ("in_stock"/"in_use"/...), which IMPORT_STATUS_MAP also
* recognizes on the way back in.
*/
export async function exportAssetItemsCsv(): Promise<string> {
const rows = await prisma.assetItem.findMany({ const rows = await prisma.assetItem.findMany({
include: { model: true, area: true },
orderBy: [{ createdAt: "desc" }, { id: "desc" }], orderBy: [{ createdAt: "desc" }, { id: "desc" }],
}); });
return rows.map(toRecord);
const header = [
"serial_number",
"asset_tag",
"manufacturer",
"model",
"purchase_date",
"warranty_end_date",
"status",
"cost",
"cost_currency",
"area",
"purchase_order_id",
"external_po_number",
];
const lines = [header.join(",")];
for (const row of rows) {
const cells = [
row.serialNumber,
row.assetTag ?? "",
row.model.manufacturer,
row.model.name,
row.purchaseDate ? row.purchaseDate.toISOString().slice(0, 10) : "",
row.warrantyEndDate ? row.warrantyEndDate.toISOString().slice(0, 10) : "",
row.status,
row.cost ? row.cost.toString() : "",
row.costCurrency ?? "",
row.area?.name ?? "",
row.purchaseOrderId ?? "",
row.externalPoNumber ?? "",
];
lines.push(cells.map(csvField).join(","));
}
return lines.join("\r\n") + "\r\n";
} }
+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 "in_use".
* - 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:
* "in_use" } })` — 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 === "in_use"` 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: "in_use" },
});
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 "in_use" 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;
}
}
+12
View File
@@ -0,0 +1,12 @@
// Shared-secret auth for the /api/bot/* routes — the inventory-capture
// Telegram bot (a separate Python service, not a browser) calls these, so
// it can't go through the Auth.js session/cookie flow the rest of the app
// uses (src/auth.ts). Deliberately a SEPARATE secret from AUTH_SECRET
// (session JWT signing key) and from the review-page User account's
// password — a machine credential, not a human one, so it can be rotated
// independently without touching either.
export function requireBotAuth(request: Request): boolean {
const header = request.headers.get("authorization") ?? "";
const expected = `Bearer ${process.env.BOT_INGEST_SECRET}`;
return Boolean(process.env.BOT_INGEST_SECRET) && header === expected;
}
+60
View File
@@ -0,0 +1,60 @@
// Shared minimal CSV parsing helpers -- extracted from
// src/lib/expected-assets.ts (2026-08-21) when src/lib/assets.ts needed
// the same line-splitting logic for AssetItem import (2026-08-23). Handles
// quoted fields (incl. embedded commas/escaped double-quotes) without a
// dependency, which is enough for this app's actual import files (simple
// flat tables, not arbitrary user-supplied CSV).
/**
* Splits one CSV line into fields. A quote only opens a quoted field when
* it's the very first character of that field -- a stray/legitimate quote
* mid-value (e.g. a 24" monitor size) is kept as a literal character
* instead of wrongly starting quoted-content mode.
*/
export function splitCsvLine(line: string): string[] {
const fields: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (inQuotes) {
if (char === '"') {
if (line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = false;
}
} else {
current += char;
}
} else if (char === '"' && current === "") {
inQuotes = true;
} else if (char === ",") {
fields.push(current);
current = "";
} else {
current += char;
}
}
fields.push(current);
return fields.map((f) => f.trim());
}
/** Splits raw CSV text into non-blank lines (handles \r\n, \r, \n). */
export function splitCsvLines(text: string): string[] {
return text.split(/\r\n|\r|\n/).filter((line) => line.trim() !== "");
}
export function normalizeHeader(header: string): string {
return header.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ");
}
/** Quotes a single CSV field for output if it contains a comma, quote, or
* newline -- doubling any embedded quotes, per RFC4180. */
export function csvField(value: string): string {
if (/[",\n]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
+203
View File
@@ -0,0 +1,203 @@
// ExpectedAsset — a row from a pre-move Excel/CSV export of known assets,
// used as a lookup/staging list for the bot's "🔍 Tìm trong list" picker
// during the office-move walk (see prisma/schema.prisma's ExpectedAsset
// model comment for the full "why" and the 2026-08-21 decision this
// follows). Two write paths: importExpectedAssets() (CSV upload, session
// route) and markExpectedAssetMatched() (bot picks one while creating a
// scan) -- same list/search read path (searchExpectedAssets()) for both
// the bot's picker and any future review UI.
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
import { splitCsvLine, splitCsvLines, normalizeHeader } from "./csv";
export interface ExpectedAssetRecord {
id: string;
asset_tag: string | null;
model_text: string;
serial_number: string | null;
old_area_text: string | null;
old_assignee_text: string | null;
matched_scan_id: string | null;
created_at: string;
}
export interface ImportExpectedAssetRow {
assetTag: string | null;
modelText: string;
serialNumber: string | null;
oldAreaText: string | null;
oldAssigneeText: string | null;
}
function toRecord(row: {
id: string;
assetTag: string | null;
modelText: string;
serialNumber: string | null;
oldAreaText: string | null;
oldAssigneeText: string | null;
matchedScanId: string | null;
createdAt: Date;
}): ExpectedAssetRecord {
return {
id: row.id,
asset_tag: row.assetTag,
model_text: row.modelText,
serial_number: row.serialNumber,
old_area_text: row.oldAreaText,
old_assignee_text: row.oldAssigneeText,
matched_scan_id: row.matchedScanId,
created_at: row.createdAt.toISOString(),
};
}
// Header names accepted per logical field, matched case/space/underscore
// -insensitively (see normalizeHeader()) -- covers plausible English and
// unaccented-Vietnamese exports without requiring the user to rename
// columns to match this app's exact field names.
const HEADER_ALIASES: Record<keyof ImportExpectedAssetRow, string[]> = {
assetTag: ["assettag", "asset tag", "tag", "matag", "ma tag", "ma so"],
modelText: ["model", "modeltext", "model text", "model name", "thietbi", "ten model"],
serialNumber: ["serial", "serialnumber", "serial number", "sn", "serial no", "so serial"],
oldAreaText: ["area", "oldarea", "old area", "location", "khuvuc", "khu vuc", "vitri", "vi tri"],
oldAssigneeText: [
"assignee",
"owner",
"oldassignee",
"old assignee",
"nguoigiu",
"nguoi giu",
"employee",
"nhanvien",
"nhan vien",
],
};
export type ParseCsvResult =
| { ok: true; rows: ImportExpectedAssetRow[] }
| { ok: false; error: string };
/**
* Parses a CSV export into rows ready for importExpectedAssets(). Header
* row is required; column order doesn't matter (matched by name via
* HEADER_ALIASES). modelText is the only field this app's schema requires
* NOT NULL, but a row missing it is not rejected outright -- defaults to
* "" rather than failing the whole import over one bad row, consistent
* with this feature's existing "don't block the walk" precedent (see
* InventoryScan.assetTag's schema comment). Fully blank rows are skipped.
*/
export function parseExpectedAssetsCsv(text: string): ParseCsvResult {
const lines = splitCsvLines(text);
if (lines.length === 0) {
return { ok: false, error: "CSV file is empty" };
}
const headerCells = splitCsvLine(lines[0]).map(normalizeHeader);
const columnIndex: Partial<Record<keyof ImportExpectedAssetRow, number>> = {};
for (const [field, aliases] of Object.entries(HEADER_ALIASES) as [
keyof ImportExpectedAssetRow,
string[],
][]) {
const idx = headerCells.findIndex((h) => aliases.includes(h));
if (idx !== -1) {
columnIndex[field] = idx;
}
}
if (columnIndex.assetTag === undefined && columnIndex.modelText === undefined) {
return {
ok: false,
error:
"Could not find an asset tag or model column in the header row -- recognized names: " +
Object.values(HEADER_ALIASES).flat().join(", "),
};
}
const rows: ImportExpectedAssetRow[] = [];
for (const line of lines.slice(1)) {
const cells = splitCsvLine(line);
const get = (field: keyof ImportExpectedAssetRow): string | null => {
const idx = columnIndex[field];
if (idx === undefined) return null;
const value = (cells[idx] ?? "").trim();
return value === "" ? null : value;
};
const row: ImportExpectedAssetRow = {
assetTag: get("assetTag"),
modelText: get("modelText") ?? "",
serialNumber: get("serialNumber"),
oldAreaText: get("oldAreaText"),
oldAssigneeText: get("oldAssigneeText"),
};
if (!row.assetTag && !row.modelText && !row.serialNumber) {
continue; // fully blank row
}
rows.push(row);
}
return { ok: true, rows };
}
export async function importExpectedAssets(
rows: ImportExpectedAssetRow[]
): Promise<{ count: number }> {
const result = await prisma.expectedAsset.createMany({
data: rows.map((row) => ({
assetTag: row.assetTag,
modelText: row.modelText,
serialNumber: row.serialNumber,
oldAreaText: row.oldAreaText,
oldAssigneeText: row.oldAssigneeText,
})),
});
return { count: result.count };
}
/**
* Unmatched (matchedScanId IS NULL) rows, optionally filtered by a
* case-insensitive substring match against assetTag/modelText/
* oldAssigneeText -- this is what the bot's "🔍 Tìm trong list" search
* step calls. Capped at 20 results; the bot renders these as inline
* keyboard buttons, so more than that is unusable anyway.
*/
export async function searchExpectedAssets(query?: string): Promise<ExpectedAssetRecord[]> {
const where: Prisma.ExpectedAssetWhereInput = { matchedScanId: null };
if (query && query.trim() !== "") {
const q = query.trim();
where.OR = [
{ assetTag: { contains: q, mode: "insensitive" } },
{ modelText: { contains: q, mode: "insensitive" } },
{ oldAssigneeText: { contains: q, mode: "insensitive" } },
];
}
const rows = await prisma.expectedAsset.findMany({
where,
orderBy: [{ createdAt: "desc" }],
take: 20,
});
return rows.map(toRecord);
}
export async function getExpectedAsset(id: string): Promise<ExpectedAssetRecord | null> {
const row = await prisma.expectedAsset.findUnique({ where: { id } });
return row ? toRecord(row) : null;
}
export type MarkMatchedResult = { ok: true } | { ok: false; error: string };
export async function markExpectedAssetMatched(
id: string,
scanId: string
): Promise<MarkMatchedResult> {
try {
await prisma.expectedAsset.update({
where: { id },
data: { matchedScanId: scanId },
});
return { ok: true };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
return { ok: false, error: "ExpectedAsset not found" };
}
throw error;
}
}
+277
View File
@@ -0,0 +1,277 @@
// InventoryScan — one physical item logged during the office-move
// inventory walk (see prisma/schema.prisma's InventoryScan model comment).
// Two write paths: createInventoryScan() (bot ingest, POST
// /api/bot/inventory-scans) and updateInventoryScan() (review-page edit,
// PATCH /api/inventory-scans/[id]) — same list/toRecord read path for both
// the bot (none needed) and the review UI.
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
export interface InventoryScanRecord {
id: string;
category_id: string;
category_name: string;
model_id: string;
model_name: string;
model_manufacturer: string;
area_id: string | null;
area_name: string | null;
assigned_employee_id: string | null;
assigned_employee_name: string | null;
asset_tag: string | null;
serial_number: string | null;
specs: Record<string, unknown>;
photo_file_id: string | null;
created_at: string;
}
export interface CreateInventoryScanInput {
categoryId: string;
modelId: string;
areaId?: string;
assignedEmployeeId?: string;
assetTag?: string;
serialNumber?: string;
specs?: Record<string, unknown>;
photoFileId?: string;
telegramChatId?: string;
telegramMessageId?: number;
// Set when the bot's "🔍 Tìm trong list" picker was used -- see
// src/lib/expected-assets.ts. Marks that ExpectedAsset row matched to
// this scan in the same transaction as the create, so it stops showing
// up in future searches.
expectedAssetId?: string;
}
export type ValidationResult =
| { ok: true; value: CreateInventoryScanInput }
| { ok: false; error: string };
const uuidField = (message: string) =>
z.string({ invalid_type_error: message }).uuid(message);
const createInventoryScanSchema = z.object({
categoryId: uuidField("categoryId must be a valid UUID string"),
modelId: uuidField("modelId must be a valid UUID string"),
areaId: uuidField("areaId must be a valid UUID string").optional(),
assignedEmployeeId: uuidField("assignedEmployeeId must be a valid UUID string").optional(),
assetTag: z.string({ invalid_type_error: "assetTag must be a string" }).optional(),
serialNumber: z.string({ invalid_type_error: "serialNumber must be a string" }).optional(),
specs: z.record(z.unknown(), { invalid_type_error: "specs must be a plain JSON object" }).optional(),
photoFileId: z.string({ invalid_type_error: "photoFileId must be a string" }).optional(),
telegramChatId: z.string({ invalid_type_error: "telegramChatId must be a string" }).optional(),
telegramMessageId: z
.number({ invalid_type_error: "telegramMessageId must be a number" })
.int()
.optional(),
expectedAssetId: uuidField("expectedAssetId must be a valid UUID string").optional(),
});
export function validateCreateInventoryScanInput(input: {
categoryId?: unknown;
modelId?: unknown;
areaId?: unknown;
assignedEmployeeId?: unknown;
assetTag?: unknown;
serialNumber?: unknown;
specs?: unknown;
photoFileId?: unknown;
telegramChatId?: unknown;
telegramMessageId?: unknown;
expectedAssetId?: unknown;
}): ValidationResult {
const result = createInventoryScanSchema.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 };
}
export interface UpdateInventoryScanInput {
modelId?: string;
areaId?: string | null;
assignedEmployeeId?: string | null;
assetTag?: string | null;
serialNumber?: string | null;
specs?: Record<string, unknown>;
}
const updateInventoryScanSchema = z.object({
modelId: uuidField("modelId must be a valid UUID string").optional(),
areaId: uuidField("areaId must be a valid UUID string").nullable().optional(),
assignedEmployeeId: uuidField("assignedEmployeeId must be a valid UUID string").nullable().optional(),
assetTag: z.string({ invalid_type_error: "assetTag must be a string" }).nullable().optional(),
serialNumber: z.string({ invalid_type_error: "serialNumber must be a string" }).nullable().optional(),
specs: z.record(z.unknown(), { invalid_type_error: "specs must be a plain JSON object" }).optional(),
});
export type UpdateValidationResult =
| { ok: true; value: UpdateInventoryScanInput }
| { ok: false; error: string };
export function validateUpdateInventoryScanInput(input: {
modelId?: unknown;
areaId?: unknown;
assignedEmployeeId?: unknown;
assetTag?: unknown;
serialNumber?: unknown;
specs?: unknown;
}): UpdateValidationResult {
const result = updateInventoryScanSchema.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 includeForRecord = {
category: true,
model: true,
area: true,
assignedEmployee: true,
} satisfies Prisma.InventoryScanInclude;
type ScanWithRelations = Prisma.InventoryScanGetPayload<{ include: typeof includeForRecord }>;
function toRecord(row: ScanWithRelations): InventoryScanRecord {
return {
id: row.id,
category_id: row.categoryId,
category_name: row.category.name,
model_id: row.modelId,
model_name: row.model.name,
model_manufacturer: row.model.manufacturer,
area_id: row.areaId,
area_name: row.area?.name ?? null,
assigned_employee_id: row.assignedEmployeeId,
assigned_employee_name: row.assignedEmployee?.name ?? null,
asset_tag: row.assetTag,
serial_number: row.serialNumber,
specs: (row.specs as Record<string, unknown>) ?? {},
photo_file_id: row.photoFileId,
created_at: row.createdAt.toISOString(),
};
}
export type CreateInventoryScanResult =
| { ok: true; value: InventoryScanRecord }
| { ok: false; error: string };
/**
* Creates an InventoryScan. categoryId/modelId/areaId/assignedEmployeeId
* are all plain FKs with no extra business rule beyond "does the row
* exist" (unlike AssetModel's high_value check) — a missing reference
* raises Prisma's P2003, translated into a clean {ok:false,error} the same
* way src/lib/employees.ts's createEmployee() handles managerId.
*/
export async function createInventoryScan(
input: CreateInventoryScanInput
): Promise<CreateInventoryScanResult> {
try {
const data = {
categoryId: input.categoryId,
modelId: input.modelId,
areaId: input.areaId ?? null,
assignedEmployeeId: input.assignedEmployeeId ?? null,
assetTag: input.assetTag ?? null,
serialNumber: input.serialNumber ?? null,
specs: (input.specs ?? {}) as Prisma.InputJsonValue,
photoFileId: input.photoFileId ?? null,
telegramChatId: input.telegramChatId ?? null,
telegramMessageId: input.telegramMessageId ?? null,
};
// When the bot's "🔍 Tìm trong list" picker was used, marking the
// ExpectedAsset row matched has to land in the same transaction as the
// create -- otherwise a crash between the two calls would silently
// leave that row unmatched and re-offer it in a later search, letting
// the same physical item get logged twice.
const created = input.expectedAssetId
? await prisma.$transaction(async (tx) => {
const scan = await tx.inventoryScan.create({ data, include: includeForRecord });
await tx.expectedAsset.update({
where: { id: input.expectedAssetId },
data: { matchedScanId: scan.id },
});
return scan;
})
: await prisma.inventoryScan.create({ data, include: includeForRecord });
return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2003") {
return { ok: false, error: "categoryId/modelId/areaId/assignedEmployeeId must reference existing rows" };
}
if (error.code === "P2025") {
return { ok: false, error: "expectedAssetId does not reference an existing row" };
}
}
throw error;
}
}
export async function listInventoryScans(): Promise<InventoryScanRecord[]> {
const rows = await prisma.inventoryScan.findMany({
include: includeForRecord,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
export type UpdateInventoryScanResult =
| { ok: true; value: InventoryScanRecord }
| { ok: false; error: string };
export async function updateInventoryScan(
id: string,
input: UpdateInventoryScanInput
): Promise<UpdateInventoryScanResult> {
try {
const updated = await prisma.inventoryScan.update({
where: { id },
data: {
modelId: input.modelId,
areaId: input.areaId,
assignedEmployeeId: input.assignedEmployeeId,
assetTag: input.assetTag,
serialNumber: input.serialNumber,
specs: input.specs as Prisma.InputJsonValue | undefined,
},
include: includeForRecord,
});
return { ok: true, value: toRecord(updated) };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2025") {
return { ok: false, error: "InventoryScan not found" };
}
if (error.code === "P2003") {
return { ok: false, error: "modelId/areaId/assignedEmployeeId must reference existing rows" };
}
}
throw error;
}
}
export type DeleteInventoryScanResult = { ok: true } | { ok: false; error: string };
export async function deleteInventoryScan(id: string): Promise<DeleteInventoryScanResult> {
try {
await prisma.inventoryScan.delete({ where: { id } });
return { ok: true };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
return { ok: false, error: "InventoryScan not found" };
}
throw error;
}
}
export async function getInventoryScan(id: string): Promise<InventoryScanRecord | null> {
const row = await prisma.inventoryScan.findUnique({ where: { id }, include: includeForRecord });
return row ? toRecord(row) : null;
}
+29 -5
View File
@@ -12,6 +12,7 @@ export interface LicenseRecord {
seats_used: number; seats_used: number;
expiry_date: string | null; expiry_date: string | null;
license_type: LicenseType; license_type: LicenseType;
purchase_order_id: string | null;
created_at: string; created_at: string;
} }
@@ -21,6 +22,7 @@ export interface CreateLicenseInput {
seatsTotal: number; seatsTotal: number;
expiryDate?: Date | null; expiryDate?: Date | null;
licenseType: LicenseType; licenseType: LicenseType;
purchaseOrderId?: string | null;
} }
export type ValidationResult = export type ValidationResult =
@@ -65,6 +67,17 @@ const createLicenseSchema = z.object({
message: "licenseType is required and must be either 'perpetual' or 'subscription'", 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(),
}); });
/** /**
@@ -75,6 +88,7 @@ const createLicenseSchema = z.object({
* seatsUsed is not accepted here — new licenses always start at 0 (same * seatsUsed is not accepted here — new licenses always start at 0 (same
* pattern as AssetItem always starting "in_stock"), seats are only ever * pattern as AssetItem always starting "in_stock"), seats are only ever
* consumed via other operations (once Assignment exists), not at creation. * 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: { export function validateCreateLicenseInput(input: {
name?: unknown; name?: unknown;
@@ -82,6 +96,7 @@ export function validateCreateLicenseInput(input: {
seatsTotal?: unknown; seatsTotal?: unknown;
expiryDate?: unknown; expiryDate?: unknown;
licenseType?: unknown; licenseType?: unknown;
purchaseOrderId?: unknown;
}): ValidationResult { }): ValidationResult {
const result = createLicenseSchema.safeParse(input); const result = createLicenseSchema.safeParse(input);
if (!result.success) { if (!result.success) {
@@ -99,6 +114,7 @@ function toRecord(row: {
seatsUsed: number; seatsUsed: number;
expiryDate: Date | null; expiryDate: Date | null;
licenseType: LicenseType; licenseType: LicenseType;
purchaseOrderId: string | null;
createdAt: Date; createdAt: Date;
}): LicenseRecord { }): LicenseRecord {
return { return {
@@ -109,6 +125,7 @@ function toRecord(row: {
seats_used: row.seatsUsed, seats_used: row.seatsUsed,
expiry_date: row.expiryDate ? row.expiryDate.toISOString() : null, expiry_date: row.expiryDate ? row.expiryDate.toISOString() : null,
license_type: row.licenseType, license_type: row.licenseType,
purchase_order_id: row.purchaseOrderId,
created_at: row.createdAt.toISOString(), created_at: row.createdAt.toISOString(),
}; };
} }
@@ -118,11 +135,13 @@ export type CreateLicenseResult =
| { ok: false; error: string }; | { ok: false; error: string };
/** /**
* Creates a License. If vendorId doesn't reference an existing Vendor, * Creates a License. Two separate FKs can fail here (vendorId -> Vendor,
* Prisma raises a known error (code P2003, FK violation) — caught here and * purchaseOrderId -> PurchaseOrder) — Prisma raises the same error code
* translated into the same `{ok:false,error}` shape as input validation * (P2003) for either, so the FK constraint name in `error.meta.field_name`
* failures, rather than letting a raw Prisma error leak to the API layer. * is used to tell them apart and return a targeted message, rather than a
* Mirrors src/lib/requests.ts's createRequest() pattern for employeeId. * 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<CreateLicenseResult> { export async function createLicense(input: CreateLicenseInput): Promise<CreateLicenseResult> {
try { try {
@@ -133,11 +152,16 @@ export async function createLicense(input: CreateLicenseInput): Promise<CreateLi
seatsTotal: input.seatsTotal, seatsTotal: input.seatsTotal,
expiryDate: input.expiryDate ?? null, expiryDate: input.expiryDate ?? null,
licenseType: input.licenseType, licenseType: input.licenseType,
purchaseOrderId: input.purchaseOrderId ?? null,
}, },
}); });
return { ok: true, value: toRecord(created) }; return { ok: true, value: toRecord(created) };
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { 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" }; return { ok: false, error: "vendorId does not reference an existing vendor" };
} }
throw error; throw error;
+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 "in_use"/"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).
// "in_use" is deliberately rejected: setting AssetItem.status to
// "in_use" 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 "in_use" 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 "in_use"
// 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;
}
}
+29 -5
View File
@@ -16,6 +16,7 @@ export interface RequestRecord {
type: RequestType; type: RequestType;
reason: string; reason: string;
status: RequestStatus; status: RequestStatus;
purchase_order_id: string | null;
created_at: string; created_at: string;
} }
@@ -23,6 +24,7 @@ export interface CreateRequestInput {
employee_id: string; employee_id: string;
type: RequestType; type: RequestType;
reason: string; reason: string;
purchase_order_id?: string | null;
} }
export type ValidationResult = export type ValidationResult =
@@ -55,6 +57,18 @@ const createRequestSchema = z.object({
message: "type is required and must be exactly 'asset' or 'license'", 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; employee_id?: unknown;
type?: unknown; type?: unknown;
reason?: unknown; reason?: unknown;
purchase_order_id?: unknown;
}): ValidationResult { }): ValidationResult {
const result = createRequestSchema.safeParse(input); const result = createRequestSchema.safeParse(input);
if (!result.success) { if (!result.success) {
@@ -81,6 +96,7 @@ function toRecord(row: {
type: RequestType; type: RequestType;
reason: string; reason: string;
status: string; status: string;
purchaseOrderId: string | null;
createdAt: Date; createdAt: Date;
}): RequestRecord { }): RequestRecord {
return { return {
@@ -89,6 +105,7 @@ function toRecord(row: {
type: row.type, type: row.type,
reason: row.reason, reason: row.reason,
status: row.status as RequestStatus, status: row.status as RequestStatus,
purchase_order_id: row.purchaseOrderId,
created_at: row.createdAt.toISOString(), created_at: row.createdAt.toISOString(),
}; };
} }
@@ -98,11 +115,13 @@ export type CreateRequestResult =
| { ok: false; error: string }; | { ok: false; error: string };
/** /**
* Creates a Request. If employee_id doesn't reference an existing Employee, * Creates a Request. Two separate FKs can fail here (employee_id ->
* Prisma raises a known error (code P2003, FK violation) — caught here and * Employee, purchase_order_id -> PurchaseOrder) — Prisma raises the same
* translated into the same `{ok:false,error}` shape as input validation * error code (P2003) for either, so the FK constraint name in
* failures, rather than letting a raw Prisma error leak to the API layer. * `error.meta.field_name` is used to tell them apart and return a targeted
* Mirrors src/lib/employees.ts's createEmployee() pattern for managerId. * 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> { export async function createRequest(input: CreateRequestInput): Promise<CreateRequestResult> {
try { try {
@@ -111,11 +130,16 @@ export async function createRequest(input: CreateRequestInput): Promise<CreateRe
employeeId: input.employee_id, employeeId: input.employee_id,
type: input.type, type: input.type,
reason: input.reason, reason: input.reason,
purchaseOrderId: input.purchase_order_id ?? null,
}, },
}); });
return { ok: true, value: toRecord(created) }; return { ok: true, value: toRecord(created) };
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { 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" }; return { ok: false, error: "employee_id does not reference an existing employee" };
} }
throw error; throw error;
+20
View File
@@ -20,6 +20,21 @@ const BCRYPT_SALT_ROUNDS = 10;
// grows into employee self-service. // grows into employee self-service.
const MIN_PASSWORD_LENGTH = 8; const MIN_PASSWORD_LENGTH = 8;
// Precomputed bcrypt hash of a fixed dummy password, hashed with the same
// cost factor (BCRYPT_SALT_ROUNDS) used for real user passwords. Used as a
// timing-safety decoy in verifyUserCredentials() below: when the looked-up
// email doesn't exist, we still run a bcrypt.compare() against this hash
// (whose result is always discarded) so that the "unknown email" code path
// takes roughly the same time as the "wrong password" path — otherwise the
// early return would let an attacker distinguish "email doesn't exist" from
// "email exists, password wrong" purely by response latency (bcrypt.compare
// dominates request time). Computed once at module load via hashSync (not
// per-request) since hashing itself is deliberately expensive.
const DUMMY_PASSWORD_HASH = bcrypt.hashSync(
"dummy-password-for-timing-safety",
BCRYPT_SALT_ROUNDS
);
export interface UserRecord { export interface UserRecord {
id: string; id: string;
email: string; email: string;
@@ -138,6 +153,11 @@ export async function verifyUserCredentials(
const user = await prisma.user.findUnique({ where: { email } }); const user = await prisma.user.findUnique({ where: { email } });
if (!user) { if (!user) {
// Run a dummy compare against a fixed decoy hash and discard the result
// — see DUMMY_PASSWORD_HASH comment above. This keeps timing consistent
// with the "email exists, password wrong" branch below instead of
// returning early.
await bcrypt.compare(password, DUMMY_PASSWORD_HASH);
return null; return null;
} }
+118
View File
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
// Mirrors tests/employees.test.ts's @/auth mocking pattern.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/areas/route";
import { prisma } from "@/lib/prisma";
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/areas", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(async () => {
await prisma.area.deleteMany();
mockedAuth.mockResolvedValue(testSession);
});
describe("POST /api/areas", () => {
it("creates an area with no team on valid input", async () => {
const res = await POST(postRequest({ name: "Phong hop A" }));
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({ name: "Phong hop A", team: null });
expect(typeof body.id).toBe("string");
});
it("creates an area with a team", async () => {
const res = await POST(postRequest({ name: "Seat 12", team: "Backend" }));
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({ name: "Seat 12", team: "Backend" });
});
it("rejects an empty name", async () => {
const res = await POST(postRequest({ name: "" }));
expect(res.status).toBe(400);
const body = await res.json();
expect(typeof body.error).toBe("string");
});
// See src/lib/areas.ts's createOrGetArea() comment: this is deliberate,
// not accidental idempotency — the bot's "pick from list or type new"
// flow must be able to re-submit a name it just showed as an existing
// option without erroring.
it("returns the existing row (200-equivalent 201, same id) instead of erroring on a duplicate name", async () => {
const first = await POST(postRequest({ name: "Kho tang 2" }));
const firstBody = await first.json();
const second = await POST(postRequest({ name: "Kho tang 2" }));
expect(second.status).toBe(201);
const secondBody = await second.json();
expect(secondBody.id).toBe(firstBody.id);
const all = await prisma.area.findMany();
expect(all).toHaveLength(1);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/areas", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
});
});
describe("GET /api/areas", () => {
it("lists areas alphabetically by name", async () => {
await POST(postRequest({ name: "Zeta" }));
await POST(postRequest({ name: "Alpha" }));
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body.map((a: { name: string }) => a.name)).toEqual(["Alpha", "Zeta"]);
});
it("returns an empty list when there are no areas", async () => {
const res = await GET();
expect(res.status).toBe(200);
expect(await res.json()).toEqual([]);
});
});
describe("auth gating on /api/areas", () => {
it("POST /api/areas returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await POST(postRequest({ name: "Phong hop A" }));
expect(res.status).toBe(401);
expect(await prisma.area.findMany()).toHaveLength(0);
});
it("GET /api/areas returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(401);
});
});
+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" });
});
});
+234 -24
View File
@@ -24,10 +24,85 @@ function postRequest(body: unknown): Request {
}); });
} }
// GET /api/assets takes a Request now (2026-08-25, see route.ts) so it can
// read pagination/filter query params -- query is an optional raw query
// string (e.g. "status=in_use&page=2"), empty by default.
function getRequest(query = ""): Request {
return new Request(`http://localhost/api/assets${query ? `?${query}` : ""}`);
}
// 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 () => { beforeEach(async () => {
// Each test starts from an empty table so listing/order assertions are // Each test starts from empty tables so listing/order assertions are
// deterministic. // 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.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 // Default to a logged-in session — the tests below exercise business
// logic, not auth gating (that's covered by the "auth gating" describe // logic, not auth gating (that's covered by the "auth gating" describe
// block further down). // block further down).
@@ -36,8 +111,9 @@ beforeEach(async () => {
describe("POST /api/assets", () => { describe("POST /api/assets", () => {
it("creates an asset item in in_stock status on valid input, with no warrantyEndDate", async () => { it("creates an asset item in in_stock status on valid input, with no warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST( 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); expect(res.status).toBe(201);
@@ -45,7 +121,7 @@ describe("POST /api/assets", () => {
expect(body).toMatchObject({ expect(body).toMatchObject({
serial_number: "SN-001", serial_number: "SN-001",
model: "Dell Latitude 5440", model_id: modelId,
status: "in_stock", status: "in_stock",
warranty_end_date: null, warranty_end_date: null,
}); });
@@ -56,10 +132,11 @@ describe("POST /api/assets", () => {
}); });
it("creates an asset item with a warrantyEndDate", async () => { it("creates an asset item with a warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST( const res = await POST(
postRequest({ postRequest({
serialNumber: "SN-002", serialNumber: "SN-002",
model: "Dell Latitude 5440", modelId,
purchaseDate: "2026-01-15", purchaseDate: "2026-01-15",
warrantyEndDate: "2029-01-15", warrantyEndDate: "2029-01-15",
}) })
@@ -71,24 +148,26 @@ describe("POST /api/assets", () => {
}); });
it("rejects an empty serialNumber", async () => { 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); expect(res.status).toBe(400);
const body = await res.json(); const body = await res.json();
expect(body.error).toMatch(/serialNumber/i); expect(body.error).toMatch(/serialNumber/i);
}); });
it("rejects an empty model", async () => { it("rejects a missing modelId", async () => {
const res = await POST(postRequest({ serialNumber: "SN-003", model: "", purchaseDate: "2026-01-15" })); const res = await POST(postRequest({ serialNumber: "SN-003", purchaseDate: "2026-01-15" }));
expect(res.status).toBe(400); expect(res.status).toBe(400);
const body = await res.json(); const body = await res.json();
expect(body.error).toMatch(/model/i); expect(body.error).toMatch(/modelId/i);
}); });
it("rejects an invalid purchaseDate", async () => { it("rejects an invalid purchaseDate", async () => {
const modelId = await createTestModelId();
const res = await POST( 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); expect(res.status).toBe(400);
@@ -97,10 +176,11 @@ describe("POST /api/assets", () => {
}); });
it("rejects an invalid warrantyEndDate", async () => { it("rejects an invalid warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST( const res = await POST(
postRequest({ postRequest({
serialNumber: "SN-005", serialNumber: "SN-005",
model: "Dell", modelId,
purchaseDate: "2026-01-15", purchaseDate: "2026-01-15",
warrantyEndDate: "not-a-date", warrantyEndDate: "not-a-date",
}) })
@@ -111,9 +191,77 @@ describe("POST /api/assets", () => {
expect(body.error).toMatch(/warrantyEndDate/i); 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 () => { it("rejects a duplicate serialNumber", async () => {
await POST(postRequest({ serialNumber: "SN-006", model: "Dell", purchaseDate: "2026-01-15" })); const modelId = await createTestModelId();
const res = await POST(postRequest({ serialNumber: "SN-006", model: "HP", purchaseDate: "2026-02-01" })); 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); expect(res.status).toBe(400);
const body = await res.json(); const body = await res.json();
@@ -123,32 +271,93 @@ describe("POST /api/assets", () => {
const remaining = await prisma.assetItem.findMany(); const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(1); 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", () => { describe("GET /api/assets", () => {
it("lists created asset items, newest first", async () => { 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. // Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5)); 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(); const res = await GET(getRequest());
expect(res.status).toBe(200); expect(res.status).toBe(200);
// {items, total} now (2026-08-25, see listAssetItems()'s doc comment
// in src/lib/assets.ts) -- was a bare array before pagination.
const body = await res.json(); const body = await res.json();
expect(Array.isArray(body)).toBe(true); expect(Array.isArray(body.items)).toBe(true);
expect(body).toHaveLength(2); expect(body.items).toHaveLength(2);
expect(body.total).toBe(2);
// newest first // newest first
expect(body[0]).toMatchObject({ serial_number: "SN-011", model: "HP" }); expect(body.items[0]).toMatchObject({ serial_number: "SN-011", model_id: modelId });
expect(body[1]).toMatchObject({ serial_number: "SN-010", model: "Dell" }); expect(body.items[1]).toMatchObject({ serial_number: "SN-010", model_id: modelId });
}); });
it("returns an empty list when there are no asset items", async () => { it("returns an empty list when there are no asset items", async () => {
const res = await GET(); const res = await GET(getRequest());
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(body).toEqual([]); expect(body).toEqual({ items: [], total: 0 });
}); });
}); });
@@ -157,10 +366,11 @@ describe("auth gating on /api/assets", () => {
// session presence, per the flat permission model (no role/permission // session presence, per the flat permission model (no role/permission
// logic). // logic).
it("POST /api/assets returns 401 without a session", async () => { it("POST /api/assets returns 401 without a session", async () => {
const modelId = await createTestModelId();
mockedAuth.mockResolvedValue(null); mockedAuth.mockResolvedValue(null);
const res = await POST( 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); expect(res.status).toBe(401);
@@ -175,7 +385,7 @@ describe("auth gating on /api/assets", () => {
it("GET /api/assets returns 401 without a session", async () => { it("GET /api/assets returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null); mockedAuth.mockResolvedValue(null);
const res = await GET(); const res = await GET(getRequest());
expect(res.status).toBe(401); expect(res.status).toBe(401);
const body = await res.json(); const body = await res.json();
+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" | "in_use" | "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 in_use", 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("in_use");
});
it.each(["repair", "retired", "in_use"] 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("in_use");
});
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("in_use");
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 in_use 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 "in_use" 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("in_use");
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("in_use");
});
});
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("in_use");
});
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(email, undefined)).toBeNull();
expect(await verifyUserCredentials(123, password)).toBeNull(); expect(await verifyUserCredentials(123, password)).toBeNull();
}); });
// Timing side-channel fix: an unknown email must not short-circuit to a
// different outcome shape than a known email with the wrong password —
// both must be indistinguishable (null) so a caller/attacker can't infer
// which case occurred from the result alone. (The actual timing-safety
// property — running a dummy bcrypt.compare() on the unknown-email path —
// isn't asserted here since timing isn't reliably testable in a unit
// test; this only pins down the observable behavior staying identical.)
it("returns the same result (null) for an unknown email as for a known email with the wrong password", async () => {
const unknownEmailResult = await verifyUserCredentials("[email protected]", password);
const wrongPasswordResult = await verifyUserCredentials(email, "wrong-password");
expect(unknownEmailResult).toBeNull();
expect(wrongPasswordResult).toBeNull();
expect(unknownEmailResult).toBe(wrongPasswordResult);
});
}); });
+214
View File
@@ -0,0 +1,214 @@
import { beforeEach, describe, expect, it } from "vitest";
// Covers every /api/bot/* route — these are authed by a shared secret
// header (src/lib/bot-auth.ts), not an Auth.js session, since the caller is
// the inventory-capture Telegram bot service, not a browser. No @/auth
// mock needed here (unlike tests/employees.test.ts and friends).
process.env.BOT_INGEST_SECRET = "test-bot-secret";
import { GET as getCatalog } from "@/app/api/bot/catalog/route";
import { GET as getBotAreas, POST as postBotAreas } from "@/app/api/bot/areas/route";
import { GET as getBotEmployees, POST as postBotEmployees } from "@/app/api/bot/employees/route";
import { POST as postBotAssetModel } from "@/app/api/bot/asset-models/route";
import { POST as postBotInventoryScan } from "@/app/api/bot/inventory-scans/route";
import { prisma } from "@/lib/prisma";
const AUTH_HEADER = { authorization: "Bearer test-bot-secret" };
function req(url: string, opts: { method?: string; body?: unknown; auth?: boolean } = {}): Request {
return new Request(`http://localhost${url}`, {
method: opts.method ?? "GET",
headers: {
"content-type": "application/json",
...(opts.auth === false ? {} : AUTH_HEADER),
},
...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
});
}
async function seedCategoryAndModel() {
const category = await prisma.assetCategory.create({
data: { name: "Workstation", valueGroup: "high_value" },
});
await prisma.assetCategoryAttributeDef.create({
data: { categoryId: category.id, key: "cpu", label: "CPU", dataType: "string", required: false, sortOrder: 1 },
});
const model = await prisma.assetModel.create({
data: { categoryId: category.id, manufacturer: "HP", name: "Z4 G4", specs: {} },
});
return { category, model };
}
beforeEach(async () => {
await prisma.inventoryScan.deleteMany();
await prisma.assetCategoryAttributeDef.deleteMany();
await prisma.assetModel.deleteMany();
await prisma.assetCategory.deleteMany();
await prisma.area.deleteMany();
await prisma.employee.deleteMany();
});
describe("GET /api/bot/catalog", () => {
it("returns categories with nested models and attribute defs", async () => {
await seedCategoryAndModel();
const res = await getCatalog(req("/api/bot/catalog"));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.categories).toHaveLength(1);
expect(body.categories[0]).toMatchObject({
name: "Workstation",
models: [{ manufacturer: "HP", name: "Z4 G4" }],
attribute_defs: [{ key: "cpu", label: "CPU", data_type: "string" }],
});
});
it("returns 401 with a wrong secret", async () => {
const res = await getCatalog(
new Request("http://localhost/api/bot/catalog", {
headers: { authorization: "Bearer wrong-secret" },
})
);
expect(res.status).toBe(401);
});
it("returns 401 with no auth header", async () => {
const res = await getCatalog(req("/api/bot/catalog", { auth: false }));
expect(res.status).toBe(401);
});
});
describe("/api/bot/areas", () => {
it("POST creates an area, and re-posting the same name returns the same row (idempotent)", async () => {
const first = await postBotAreas(req("/api/bot/areas", { method: "POST", body: { name: "Seat 12" } }));
expect(first.status).toBe(201);
const firstBody = await first.json();
const second = await postBotAreas(req("/api/bot/areas", { method: "POST", body: { name: "Seat 12" } }));
const secondBody = await second.json();
expect(secondBody.id).toBe(firstBody.id);
});
it("GET lists areas", async () => {
await postBotAreas(req("/api/bot/areas", { method: "POST", body: { name: "Seat 12" } }));
const res = await getBotAreas(req("/api/bot/areas"));
const body = await res.json();
expect(body.areas).toHaveLength(1);
});
it("POST returns 401 without the bot secret", async () => {
const res = await postBotAreas(
req("/api/bot/areas", { method: "POST", body: { name: "Seat 12" }, auth: false })
);
expect(res.status).toBe(401);
});
});
describe("/api/bot/employees", () => {
it("POST creates an employee, defaulting department when omitted", async () => {
const res = await postBotEmployees(
req("/api/bot/employees", { method: "POST", body: { name: "Toan Hoang" } })
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.name).toBe("Toan Hoang");
expect(body.department).toBeTruthy();
});
it("GET lists employees", async () => {
await postBotEmployees(req("/api/bot/employees", { method: "POST", body: { name: "Toan Hoang" } }));
const res = await getBotEmployees(req("/api/bot/employees"));
const body = await res.json();
expect(body.employees).toHaveLength(1);
});
it("POST returns 401 without the bot secret", async () => {
const res = await postBotEmployees(
req("/api/bot/employees", { method: "POST", body: { name: "Toan" }, auth: false })
);
expect(res.status).toBe(401);
});
});
describe("POST /api/bot/asset-models", () => {
it("creates a model within an existing high_value category", async () => {
const { category } = await seedCategoryAndModel();
const res = await postBotAssetModel(
req("/api/bot/asset-models", {
method: "POST",
body: { categoryId: category.id, manufacturer: "Lenovo", name: "P620" },
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({ manufacturer: "Lenovo", name: "P620" });
});
it("returns 401 without the bot secret", async () => {
const { category } = await seedCategoryAndModel();
const res = await postBotAssetModel(
req("/api/bot/asset-models", {
method: "POST",
body: { categoryId: category.id, manufacturer: "Lenovo", name: "P620" },
auth: false,
})
);
expect(res.status).toBe(401);
});
});
describe("POST /api/bot/inventory-scans", () => {
it("creates a scan with specs, asset tag and serial number", async () => {
const { category, model } = await seedCategoryAndModel();
const res = await postBotInventoryScan(
req("/api/bot/inventory-scans", {
method: "POST",
body: {
categoryId: category.id,
modelId: model.id,
assetTag: "TAG-1",
serialNumber: "SN-1",
specs: { cpu: "i7" },
telegramChatId: "12345",
},
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({
category_name: "Workstation",
model_name: "Z4 G4",
asset_tag: "TAG-1",
serial_number: "SN-1",
specs: { cpu: "i7" },
});
});
it("rejects a categoryId/modelId that doesn't reference an existing row", async () => {
const res = await postBotInventoryScan(
req("/api/bot/inventory-scans", {
method: "POST",
body: {
categoryId: "00000000-0000-0000-0000-000000000000",
modelId: "00000000-0000-0000-0000-000000000000",
},
})
);
expect(res.status).toBe(400);
});
it("returns 401 without the bot secret", async () => {
const { category, model } = await seedCategoryAndModel();
const res = await postBotInventoryScan(
req("/api/bot/inventory-scans", {
method: "POST",
body: { categoryId: category.id, modelId: model.id },
auth: false,
})
);
expect(res.status).toBe(401);
expect(await prisma.inventoryScan.findMany()).toHaveLength(0);
});
});
+24
View File
@@ -125,6 +125,30 @@ describe("POST /api/employees", () => {
}); });
expect(updated.managerId).toBe(created.id); expect(updated.managerId).toBe(created.id);
}); });
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/employees", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body", async () => {
// Note: arrays are `typeof "object"` in JS, so they pass the route's
// object check — use a JSON string primitive here to actually exercise
// the "not an object" branch (mirrors tests/licenses.test.ts).
const res = await POST(postRequest("not an object"));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
}); });
describe("GET /api/employees", () => { describe("GET /api/employees", () => {
+196
View File
@@ -0,0 +1,196 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
// Mirrors tests/employees.test.ts's @/auth mocking pattern. Creation of
// InventoryScan rows only happens via the bot-secret-authed
// /api/bot/inventory-scans route (see tests/bot-api.test.ts) — this file
// covers the session-authed human routes only (list/edit/delete), so test
// data is seeded directly via prisma, not through POST.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET as listScans } from "@/app/api/inventory-scans/route";
import { PATCH, DELETE } from "@/app/api/inventory-scans/[id]/route";
import { prisma } from "@/lib/prisma";
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(),
};
async function seedCategoryAndModel() {
const category = await prisma.assetCategory.create({
data: { name: "Controller", valueGroup: "high_value" },
});
const model = await prisma.assetModel.create({
data: { categoryId: category.id, manufacturer: "Sony", name: "PS5", specs: {} },
});
const model2 = await prisma.assetModel.create({
data: { categoryId: category.id, manufacturer: "Microsoft", name: "Xbox", specs: {} },
});
return { category, model, model2 };
}
function patchRequest(body: unknown): Request {
return new Request("http://localhost/api/inventory-scans/x", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(async () => {
await prisma.inventoryScan.deleteMany();
await prisma.assetModel.deleteMany();
await prisma.assetCategory.deleteMany();
await prisma.area.deleteMany();
await prisma.employee.deleteMany();
mockedAuth.mockResolvedValue(testSession);
});
describe("GET /api/inventory-scans", () => {
it("lists scans newest first, with joined category/model names", async () => {
const { category, model } = await seedCategoryAndModel();
const scan = await prisma.inventoryScan.create({
data: { categoryId: category.id, modelId: model.id, assetTag: "TAG-1" },
});
const res = await listScans();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveLength(1);
expect(body[0]).toMatchObject({
id: scan.id,
category_name: "Controller",
model_name: "PS5",
model_manufacturer: "Sony",
asset_tag: "TAG-1",
area_name: null,
assigned_employee_name: null,
});
});
it("returns an empty list when there are no scans", async () => {
const res = await listScans();
expect(res.status).toBe(200);
expect(await res.json()).toEqual([]);
});
});
describe("PATCH /api/inventory-scans/[id]", () => {
it("updates area, assignee, asset tag, serial number and specs", async () => {
const { category, model } = await seedCategoryAndModel();
const scan = await prisma.inventoryScan.create({
data: { categoryId: category.id, modelId: model.id },
});
const area = await prisma.area.create({ data: { name: "Phong hop A" } });
const employee = await prisma.employee.create({ data: { name: "Toan", department: "IT" } });
const res = await PATCH(
patchRequest({
areaId: area.id,
assignedEmployeeId: employee.id,
assetTag: "TAG-2",
serialNumber: "SN-123",
specs: { cpu: "i7" },
}),
{ params: { id: scan.id } }
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toMatchObject({
area_name: "Phong hop A",
assigned_employee_name: "Toan",
asset_tag: "TAG-2",
serial_number: "SN-123",
specs: { cpu: "i7" },
});
});
it("allows switching to a different model within the same category", async () => {
const { category, model, model2 } = await seedCategoryAndModel();
const scan = await prisma.inventoryScan.create({
data: { categoryId: category.id, modelId: model.id },
});
const res = await PATCH(patchRequest({ modelId: model2.id }), { params: { id: scan.id } });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.model_name).toBe("Xbox");
});
it("clears area/assignee when explicitly set to null", async () => {
const { category, model } = await seedCategoryAndModel();
const area = await prisma.area.create({ data: { name: "Phong hop A" } });
const scan = await prisma.inventoryScan.create({
data: { categoryId: category.id, modelId: model.id, areaId: area.id },
});
const res = await PATCH(patchRequest({ areaId: null }), { params: { id: scan.id } });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.area_id).toBeNull();
});
it("returns 404 for an id that doesn't exist", async () => {
const res = await PATCH(patchRequest({ assetTag: "x" }), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(404);
});
it("returns 400 for a malformed id", async () => {
const res = await PATCH(patchRequest({ assetTag: "x" }), { params: { id: "not-a-uuid" } });
expect(res.status).toBe(400);
});
});
describe("DELETE /api/inventory-scans/[id]", () => {
it("deletes an existing scan", async () => {
const { category, model } = await seedCategoryAndModel();
const scan = await prisma.inventoryScan.create({
data: { categoryId: category.id, modelId: model.id },
});
const res = await DELETE(new Request("http://localhost/api/inventory-scans/x", { method: "DELETE" }), {
params: { id: scan.id },
});
expect(res.status).toBe(204);
expect(await prisma.inventoryScan.findUnique({ where: { id: scan.id } })).toBeNull();
});
it("returns 404 for an id that doesn't exist", async () => {
const res = await DELETE(new Request("http://localhost/api/inventory-scans/x", { method: "DELETE" }), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(404);
});
});
describe("auth gating on /api/inventory-scans", () => {
it("GET returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await listScans();
expect(res.status).toBe(401);
});
it("PATCH returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await PATCH(patchRequest({ assetTag: "x" }), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(401);
});
it("DELETE returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await DELETE(new Request("http://localhost/api/inventory-scans/x", { method: "DELETE" }), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(401);
});
});
+161 -2
View File
@@ -49,11 +49,29 @@ function createTestVendor(overrides: { name?: string } = {}) {
}); });
} }
// 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 () => { beforeEach(async () => {
// Each test starts from empty tables so listing/order assertions are // Each test starts from empty tables so listing/order assertions are
// deterministic. licenses must be cleared before vendors since // deterministic. licenses must be cleared before purchase_orders/vendors
// licenses.vendor_id has an FK (ON DELETE RESTRICT) to 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.license.deleteMany();
await prisma.purchaseOrder.deleteMany();
await prisma.vendor.deleteMany(); await prisma.vendor.deleteMany();
// Default to a logged-in session — the lib-level tests below don't go // 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 // through the route at all (so this is a no-op for them), and the
@@ -222,6 +240,21 @@ describe("validateCreateLicenseInput", () => {
expect(result.error).toMatch(/licenseType/i); 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", () => { describe("createLicense", () => {
@@ -298,6 +331,64 @@ describe("createLicense", () => {
const remaining = await prisma.license.findMany(); const remaining = await prisma.license.findMany();
expect(remaining).toHaveLength(0); 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", () => { describe("listLicenses", () => {
@@ -471,6 +562,74 @@ describe("POST /api/licenses", () => {
expect(body.error).toMatch(/licenseType/i); 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 () => { it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/licenses", { const req = new Request("http://localhost/api/licenses", {
method: "POST", method: "POST",
+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" | "in_use" | "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: "in_use" });
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: "in_use" });
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);
});
});
+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 () => { beforeEach(async () => {
// Each test starts from empty tables so listing/order assertions are // Each test starts from empty tables so listing/order assertions are
// deterministic. requests must be cleared before employees since // deterministic. requests must be cleared before employees/purchase_orders
// requests.employee_id has an FK (ON DELETE RESTRICT) to employees. // 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.request.deleteMany();
await prisma.purchaseOrder.deleteMany();
await prisma.employee.deleteMany(); await prisma.employee.deleteMany();
await prisma.vendor.deleteMany();
// Default to a logged-in session — the tests below exercise request // Default to a logged-in session — the tests below exercise request
// business logic, not auth gating (that's covered by the "auth gating" // business logic, not auth gating (that's covered by the "auth gating"
// describe block further down). // describe block further down).
@@ -141,6 +173,96 @@ describe("POST /api/requests", () => {
const remaining = await prisma.request.findMany(); const remaining = await prisma.request.findMany();
expect(remaining).toHaveLength(0); 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", () => { describe("GET /api/requests", () => {
+62 -19
View File
@@ -1,18 +1,46 @@
// Creates a throwaway Postgres database per test-file run against the // Creates a throwaway Postgres database per test-file run against the
// docker-compose `db` service, points DATABASE_URL at it so tests never // docker-compose `db` service, and drops it afterwards. Mirrors the old
// touch a shared dev database, and drops it afterwards. Mirrors the old
// sqlite temp-file-per-run isolation, adapted to Postgres (CREATE/DROP // sqlite temp-file-per-run isolation, adapted to Postgres (CREATE/DROP
// DATABASE instead of a throwaway file). // DATABASE instead of a throwaway file).
// //
// Creating/dropping the throwaway database itself stays raw `pg` (test // Creating/dropping the throwaway database itself stays raw `pg` (test
// infra, not the app's data-access layer) — but applying the schema to the // infra, not the app's data-access layer) — but applying the schema to the
// fresh database, and tearing down the pooled connection afterwards, now // fresh database goes through `prisma migrate deploy`, and the actual
// goes through Prisma instead of the old db.ts. // per-file PrismaClient used by both test bodies and app code (route
// handlers etc., all of which import `prisma` from "@/lib/prisma") is a
// throwaway PrismaClient instance created here, wired up explicitly.
//
// Root-cause note (2026-08-04): this used to work by mutating the global
// `process.env.DATABASE_URL` in a `beforeAll` and relying on the app's
// `src/lib/prisma.ts` singleton to pick that up. That's unsafe the moment
// two test files' setup/query timing can interleave within the same
// worker process — `process.env` is shared across all test files that
// happen to run in the same worker (Vitest's `test.isolate` resets each
// file's *module registry*, not `process.env`), so one file's mutation can
// leak into another's PrismaClient construction/reconnect. That forced
// `fileParallelism: false` as a blunt workaround (see git blame on
// vitest.config.ts), and even that stopped being sufficient once the
// worker pool got reused across enough sequential files (12 test files —
// see git history around this comment for the concrete FK-violation
// failures that motivated this rewrite).
//
// Fix: never touch `process.env.DATABASE_URL` at all. Instead, `vi.mock`
// "@/lib/prisma" (the same module id every test file and every app module
// imports `prisma` from) with a factory that creates this file's own
// throwaway database and returns a dedicated `PrismaClient` pointed at it
// via the explicit `datasourceUrl` constructor option (Prisma 5.2+) —
// never via the ambient env var. `vi.mock` factories are cached per
// isolated test-file module registry, so every import of "@/lib/prisma"
// within a given test file (the test file itself, and any route handler /
// lib module it pulls in) resolves to that one file-scoped client, while
// two different test files never share any mutable global state. That
// makes `fileParallelism: true` (the default, restored in
// vitest.config.ts) safe again.
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { Client } from "pg"; import { Client } from "pg";
import { afterAll, beforeAll } from "vitest"; import { PrismaClient } from "@prisma/client";
import { prisma } from "@/lib/prisma"; import { afterAll, vi } from "vitest";
const baseUrl = new URL( const baseUrl = new URL(
process.env.DATABASE_URL || process.env.DATABASE_URL ||
@@ -26,6 +54,12 @@ function adminUrl(): string {
return url.toString(); return url.toString();
} }
function testDbUrl(): string {
const url = new URL(baseUrl.toString());
url.pathname = `/${testDbName}`;
return url.toString();
}
async function withAdminClient<T>(fn: (client: Client) => Promise<T>): Promise<T> { async function withAdminClient<T>(fn: (client: Client) => Promise<T>): Promise<T> {
const client = new Client({ connectionString: adminUrl() }); const client = new Client({ connectionString: adminUrl() });
await client.connect(); await client.connect();
@@ -36,27 +70,36 @@ async function withAdminClient<T>(fn: (client: Client) => Promise<T>): Promise<T
} }
} }
beforeAll(async () => { // Set by the "@/lib/prisma" mock factory below once it has created this
await withAdminClient((client) => client.query(`CREATE DATABASE ${testDbName}`)); // file's throwaway database and client, so afterAll can tear both down.
// Populated synchronously-in-effect before any test body can run, since
// the mock factory is awaited as part of resolving the first import of
// "@/lib/prisma" (which every test file and every route/lib module under
// test does at the top of its own module graph, ahead of any test body).
let testPrisma: PrismaClient | undefined;
const testUrl = new URL(baseUrl.toString()); vi.mock("@/lib/prisma", async () => {
testUrl.pathname = `/${testDbName}`; await withAdminClient((client) => client.query(`CREATE DATABASE ${testDbName}`));
process.env.DATABASE_URL = testUrl.toString();
// Apply the Prisma migration history to the fresh throwaway database. // Apply the Prisma migration history to the fresh throwaway database.
// `migrate deploy` replays the same real migrations production uses // `migrate deploy` replays the same real migrations production uses
// (prisma/migrations), rather than a schema-diffing shortcut like // (prisma/migrations), rather than a schema-diffing shortcut like
// `db push` — keeping this test setup honest about what actually runs // `db push` — keeping this test setup honest about what actually runs
// outside tests too. // outside tests too. Pass the target database explicitly via the
execSync("npx prisma migrate deploy", { stdio: "inherit" }); // subprocess's own `env` rather than mutating this process's
}, 30_000); // `process.env` — the whole point of this rewrite is to avoid any
// shared mutable global between test files.
execSync("npx prisma migrate deploy", {
stdio: "inherit",
env: { ...process.env, DATABASE_URL: testDbUrl() },
});
testPrisma = new PrismaClient({ datasourceUrl: testDbUrl() });
return { prisma: testPrisma };
});
afterAll(async () => { afterAll(async () => {
// Disconnect the app's Prisma Client singleton first so the admin await testPrisma?.$disconnect();
// connection is free to drop the database (DROP DATABASE ... WITH
// (FORCE) would also disconnect it, but closing cleanly first avoids
// relying on that).
await prisma.$disconnect();
await withAdminClient((client) => await withAdminClient((client) =>
client.query(`DROP DATABASE IF EXISTS ${testDbName} WITH (FORCE)`) client.query(`DROP DATABASE IF EXISTS ${testDbName} WITH (FORCE)`)
); );
+17 -13
View File
@@ -9,19 +9,23 @@ export default defineConfig({
}, },
test: { test: {
environment: "node", environment: "node",
// Scope explicitly to this repo's own tests/ dir — without this, Vitest's
// default glob also picks up any stray sibling git worktree checked out
// under .claude/worktrees/ (e.g. worktree-ui-basic), which isn't excluded
// by Vitest's defaults since those only skip node_modules/.git/etc, not
// .claude/. A stray worktree's stale test file still resolves the "@"
// alias to *this* repo's src/ (see resolve.alias above, which is
// __dirname-relative), so it silently runs old tests against current
// code under a shared identity — confirmed happening in practice once a
// second worktree existed.
include: ["tests/**/*.test.ts"],
setupFiles: ["./tests/setup.ts"], setupFiles: ["./tests/setup.ts"],
// Each test file spins up its own throwaway Postgres database by // Each test file gets its own throwaway Postgres database and its own
// mutating process.env.DATABASE_URL in tests/setup.ts's beforeAll (see // dedicated PrismaClient, wired up via a `vi.mock("@/lib/prisma", ...)`
// that file). That per-file DB switch is not safe under Vitest's default // factory in tests/setup.ts (not by mutating the ambient
// parallel file execution: two files' beforeAll/query timing can // process.env.DATABASE_URL, which used to be shared/racy across files —
// interleave such that both end up issuing real queries against the // see the long comment at the top of tests/setup.ts for the history).
// same throwaway DB (confirmed empirically once Request gained a real // With no mutable state shared between files, parallel file execution
// FK to Employee — tests/employees.test.ts and tests/requests.test.ts // (Vitest's default) is safe again.
// started intermittently deleting/reading each other's rows, causing
// spurious FK-violation and row-count failures that disappear when run
// sequentially). Disabling file parallelism trades a bit of wall-clock
// test time for correctness — each file's throwaway DB is now genuinely
// private for the duration of that file's run.
fileParallelism: false,
}, },
}); });