Compare commits
10
Commits
e30489297c
...
acc79a1d3c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acc79a1d3c | ||
|
|
9773dd8a39 | ||
|
|
84e6db7406 | ||
|
|
77dbd15450 | ||
|
|
ae84737620 | ||
|
|
17c5882e86 | ||
|
|
d5a0f1cb74 | ||
|
|
8a02a7c7ef | ||
|
|
1e6dec9cf2 | ||
|
|
3078d9e8c5 |
@@ -0,0 +1,48 @@
|
||||
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
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgres://asset_license_mgmt:change-me-local-dev@localhost: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
|
||||
@@ -92,6 +92,8 @@ Backend CRUD xong cho toàn bộ entity chốt tới nay: `Request` (chỉ state
|
||||
- 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.
|
||||
- **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ở
|
||||
|
||||
@@ -110,6 +112,9 @@ Backend CRUD xong cho toàn bộ entity chốt tới nay: `Request` (chỉ state
|
||||
- Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled.
|
||||
- UI cho các state Request kế tiếp (hiện chỉ có Draft) — chờ spec/build state machine ở trên trước. (Lưu ý: có thể đã có tiến triển từ 1 phiên Claude Code khác chạy song song — kiểm tra `git status`/working tree trước khi bắt đầu để tránh làm trùng.)
|
||||
- Auth/phân quyền (ai được approve request, ai được xác nhận nhận hàng/bàn giao) — vẫn flat, chưa có role.
|
||||
- `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.
|
||||
|
||||
---
|
||||
Cập nhật lần cuối: 2026-08-04 (UI cho `MaintenanceRecord` xong — list+create+close; UI transfer cho `Assignment` xong)
|
||||
|
||||
@@ -140,6 +140,26 @@ Xuất phát từ yêu cầu stakeholder: "muốn thêm 1 menu maintenance — k
|
||||
- **Đó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)
|
||||
|
||||
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).
|
||||
@@ -198,11 +218,12 @@ QR gán asset vào slot (bước 1, thủ công hàng ngày) **không phải IoT
|
||||
- Migrate 2 request đang có (`Firefly_License_Request.docx`, `Eric Pelatan.docx`, ở workspace `ubisoft/` ngoài repo này) vào hệ thống mới sau khi có DB
|
||||
- **`MaintenanceRecord` (spec BA + stakeholder, chốt 2026-08-04): đã có spec đầy đủ, sẵn sàng cho `pm` chia task** — xem mục "MaintenanceRecord" phía trên. Field cơ bản + `type` (`repair`/`warranty`/`preventive`) + `vendorId` (tái dùng `Vendor`, nullable) đã chốt; đồng bộ `AssetItem.status` đã chốt rõ (auto → `repair` lúc mở, chặn nếu có `Assignment` active hoặc đã `retired`, `targetStatus` bắt buộc lúc đóng); không track `cost`.
|
||||
- (Xa hơn, chưa ưu tiên) Rack/Slot + IoT pick-to-light, cron email cảnh báo license hết hạn, Power BI report views
|
||||
- Setup Gitea tự host ở nhà (2026-07-31) — dự định làm remote chính sau này, giữ GitHub làm backup off-site (đây là "sản phẩm dùng cả đời" của user, không nên chỉ có 1 bản duy nhất). Tạm thời vẫn push GitHub như hiện tại, chưa làm gấp.
|
||||
- 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
|
||||
|
||||
- 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-04 (spec `MaintenanceRecord` chốt đủ với stakeholder — sẵn sàng cho `pm`, xem "Việc còn dở")
|
||||
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,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;
|
||||
@@ -74,6 +74,11 @@ model Employee {
|
||||
// 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)
|
||||
|
||||
@@map("employees")
|
||||
@@ -280,6 +285,8 @@ model AssetCategory {
|
||||
// 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")
|
||||
}
|
||||
@@ -361,6 +368,8 @@ model AssetModel {
|
||||
// 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")
|
||||
}
|
||||
@@ -473,3 +482,89 @@ model MaintenanceRecord {
|
||||
|
||||
@@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[]
|
||||
|
||||
@@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)
|
||||
|
||||
@@map("inventory_scans")
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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,
|
||||
} = body as Record<string, unknown>;
|
||||
|
||||
const validation = validateCreateInventoryScanInput({
|
||||
categoryId,
|
||||
modelId,
|
||||
areaId,
|
||||
assignedEmployeeId,
|
||||
assetTag,
|
||||
serialNumber,
|
||||
specs,
|
||||
photoFileId,
|
||||
telegramChatId,
|
||||
telegramMessageId,
|
||||
});
|
||||
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,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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ const ENTITY_LINKS = [
|
||||
{ href: "/requests", label: "Requests" },
|
||||
{ href: "/assignments", label: "Assignments" },
|
||||
{ href: "/maintenance-records", label: "Maintenance Records" },
|
||||
{ href: "/inventory-scans", label: "Inventory Scans (Move)" },
|
||||
] as const;
|
||||
|
||||
export function Nav() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
export function validateCreateInventoryScanInput(input: {
|
||||
categoryId?: unknown;
|
||||
modelId?: unknown;
|
||||
areaId?: unknown;
|
||||
assignedEmployeeId?: unknown;
|
||||
assetTag?: unknown;
|
||||
serialNumber?: unknown;
|
||||
specs?: unknown;
|
||||
photoFileId?: unknown;
|
||||
telegramChatId?: unknown;
|
||||
telegramMessageId?: 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 created = await prisma.inventoryScan.create({
|
||||
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,
|
||||
},
|
||||
include: includeForRecord,
|
||||
});
|
||||
return { ok: true, value: toRecord(created) };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||
return { ok: false, error: "categoryId/modelId/areaId/assignedEmployeeId must reference existing rows" };
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user