Author SHA1 Message Date
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
24 changed files with 1862 additions and 2 deletions
+48
View File
@@ -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
+5
View File
@@ -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. - 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. - `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`). - 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ở ## 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. - 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 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. - 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) Cập nhật lần cuối: 2026-08-04 (UI cho `MaintenanceRecord` xong — list+create+close; UI transfer cho `Assignment` xong)
+23 -2
View File
@@ -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. - **Đó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). - 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).
@@ -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 - 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`. - **`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-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;
+95
View File
@@ -74,6 +74,11 @@ model Employee {
// Assignments (assets/licenses) over time, including several active ones // Assignments (assets/licenses) over time, including several active ones
// at once (e.g. a laptop + a license seat simultaneously). // at once (e.g. a laptop + a license seat simultaneously).
assignments Assignment[] 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")
@@ -280,6 +285,8 @@ model AssetCategory {
// not expressible as a Prisma-level constraint), but the relation itself // not expressible as a Prisma-level constraint), but the relation itself
// is not restricted to high_value categories at the schema level. // is not restricted to high_value categories at the schema level.
assetModels AssetModel[] assetModels AssetModel[]
// Back-relation for InventoryScan.categoryId — see InventoryScan's comment.
inventoryScans InventoryScan[]
@@map("asset_categories") @@map("asset_categories")
} }
@@ -361,6 +368,8 @@ model AssetModel {
// Back-relation for the AssetItem.model -> AssetItem.modelId retrofit — // Back-relation for the AssetItem.model -> AssetItem.modelId retrofit —
// see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md. // see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md.
assetItems AssetItem[] assetItems AssetItem[]
// Back-relation for InventoryScan.modelId — see InventoryScan's comment.
inventoryScans InventoryScan[]
@@map("asset_models") @@map("asset_models")
} }
@@ -473,3 +482,89 @@ model MaintenanceRecord {
@@map("maintenance_records") @@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")
}
+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 });
}
+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 });
}
+56
View File
@@ -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",
},
});
}
+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 });
}
+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>
);
}
+1
View File
@@ -22,6 +22,7 @@ const ENTITY_LINKS = [
{ href: "/requests", label: "Requests" }, { href: "/requests", label: "Requests" },
{ href: "/assignments", label: "Assignments" }, { href: "/assignments", label: "Assignments" },
{ href: "/maintenance-records", label: "Maintenance Records" }, { href: "/maintenance-records", label: "Maintenance Records" },
{ href: "/inventory-scans", label: "Inventory Scans (Move)" },
] as const; ] as const;
export function Nav() { export function Nav() {
+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);
}
+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;
}
+251
View File
@@ -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;
}
+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);
});
});
+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);
});
});
+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);
});
});