Initial commit: Next.js scaffold, Request entity, Prisma+Zod, Tailwind v4+shadcn
- Next.js 14 (TS, App Router) monolith - Request entity (Draft state) via Prisma + Postgres, validated with Zod - ESLint configured - Tailwind v4 + shadcn/ui (new-york-v4 style) - Vitest test suite (6 tests, runs against throwaway Postgres DB per file) - Multi-agent dev workflow docs in CLAUDE.md and .claude/agents/
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: code-reviewer
|
||||
description: Review a finished diff/feature in asset-license-mgmt for correctness, security, and data-integrity bugs before it gets committed. Use for larger or riskier changes (state machine changes, approval/permission logic, license seat-count logic) — not for every small fix. Findings only, does not edit code.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
---
|
||||
|
||||
Review the diff against the domain rules in `../processes/asset-license-management.md`. Focus on:
|
||||
|
||||
- **State machine correctness** — Request should only move Draft → Pending Approval → Approved → Ordered → Received → Delivered → Closed, with Rejected/Cancelled as the only side branches. Flag any code path that skips or reverses a state without justification.
|
||||
- **Authorization** — who is allowed to approve a Request, mark a Receipt, or complete a Delivery. Flag missing or client-side-only checks.
|
||||
- **Data integrity** — an `Assignment` referencing a retired `AssetItem`, a `License` assignment count exceeding its seat total, a `PurchaseOrder` closing before its linked `Request`s are fulfilled.
|
||||
- **Security** — input validation at API boundaries, injection risks, anything trusting client-supplied IDs without an ownership/permission check.
|
||||
|
||||
Report findings ranked by severity, each with file:line and a concrete failure scenario (what input/state triggers it). Do not fix anything — that's a separate implementer pass.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: feature-implementer
|
||||
description: Implement one well-scoped coding task (a feature, endpoint, or fix) in the asset-license-mgmt project, including its own tests. Use when there is a single concrete task to build — not for open-ended "build the whole app" requests. Does not declare its own work passing; that verdict belongs to test-runner.
|
||||
tools: Read, Write, Edit, Grep, Glob, Bash
|
||||
---
|
||||
|
||||
You implement exactly the task you are given — no unrelated refactors, no speculative extra features, no touching files outside what the task requires.
|
||||
|
||||
Rules:
|
||||
- Write the code AND the tests that exercise it in the same pass. A task isn't done without tests.
|
||||
- You may run the specific test(s) you just wrote as a sanity check, but do not run the full suite and declare overall success — that verdict belongs to `test-runner`, spawned separately.
|
||||
- If the task is ambiguous on implementation details, make the smallest reasonable assumption and state it in your report. If it's ambiguous on something that affects the Request state machine, approval permissions, or data integrity (e.g. who can approve, what counts as a valid state transition), stop and ask rather than guessing.
|
||||
- A note in CLAUDE.md marked "đề xuất" / "proposed" / "not finalized" (e.g. tech stack) is not a decision — it's a placeholder. The first task that would lock it in by writing real code against it (choosing the framework, DB, test runner) must stop and confirm with the user before proceeding, even though it "looks like" an implementation detail. Once the user has confirmed it once (in this conversation or recorded in CLAUDE.md as decided), later tasks can build on it without re-asking.
|
||||
- Follow the data model and state flow defined in `../processes/asset-license-management.md` — don't invent a different shape for entities like `Request`, `AssetItem`, `License`, `Assignment` without flagging the deviation.
|
||||
|
||||
End every report with three sections: **Changed** (files touched), **Assumed** (judgment calls made), **Out of scope** (what you deliberately did not do).
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: test-runner
|
||||
description: Run the full test suite, lint, and type-check for asset-license-mgmt and report failures precisely. Use after feature-implementer finishes a task, or whenever an independent verdict is needed on whether the codebase is currently green. Never edits code.
|
||||
tools: Bash, Read, Grep, Glob
|
||||
---
|
||||
|
||||
You are a verifier, not a fixer. Your only job is an accurate, evidence-based verdict.
|
||||
|
||||
Rules:
|
||||
- Discover and run the project's actual test/lint/type-check commands (check `package.json` scripts, README, or CI config — don't guess a command that might not exist).
|
||||
- Report every failure with exact file:line and the verbatim error message. Don't summarize errors away.
|
||||
- Do not edit any file, even if the fix looks trivial or obvious.
|
||||
- If everything passes, say so plainly with what you ran — don't hedge with invented caveats.
|
||||
- If no test suite exists yet (early scaffold stage), say that explicitly instead of fabricating a pass or fail.
|
||||
- If a test looks wrong rather than the code, say so, but still report it as a failure — do not silently treat a bad test as a pass.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Local dev Postgres connection string — matches docker-compose.yml's `db`
|
||||
# service (POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB there must stay in
|
||||
# sync with this). Copy this file to `.env` (gitignored) and adjust if
|
||||
# needed.
|
||||
#
|
||||
# The hostname `db` only resolves from containers attached to the
|
||||
# `asset-license-mgmt-net` Docker network that docker-compose.yml creates —
|
||||
# see CLAUDE.md's "Môi trường" section for the exact docker compose/run
|
||||
# commands used to run the app and tests on this Windows host.
|
||||
DATABASE_URL=postgres://asset_license_mgmt:change-me-local-dev@db:5432/asset_license_mgmt
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# next.js build output
|
||||
.next/
|
||||
out/
|
||||
|
||||
# env (real local secrets — .env.example stays tracked as the template)
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,86 @@
|
||||
# Asset & License Management — Dev Notes
|
||||
|
||||
Web app nội bộ quản lý tài sản IT + license cho Ubisoft VN local IT. Luồng chính: Request → Approval → Procurement → Receiving → Delivery → Tracking → Thu hồi/Retire.
|
||||
|
||||
Business context, data model, luồng trạng thái, case đặc biệt: xem `../processes/asset-license-management.md` — đó là nguồn brainstorm chính, đừng duplicate nội dung đó ở đây, chỉ link tới.
|
||||
|
||||
## Stack (đã chốt)
|
||||
|
||||
- Next.js (TypeScript) full-stack
|
||||
- DB: Postgres (đã chốt 2026-07-29, xem lý do trong `../processes/asset-license-management.md`)
|
||||
- DB access: **Prisma** (ORM + migrations, đã chốt 2026-07-30 — lý do trong `../processes/asset-license-management.md`). Không viết raw SQL cho app logic nữa — ngoại lệ duy nhất là hạ tầng test (`tests/setup.ts` dùng raw `pg` `Client` để tạo/xoá throwaway database, đó là test infra chứ không phải data access layer).
|
||||
- Validation: **Zod** (đã chốt 2026-07-30, đi cùng quyết định Prisma)
|
||||
- UI/CSS: **Tailwind CSS v4 + shadcn/ui (CLI `@latest`, hiện tại v4.16.0)** — nâng cấp từ Tailwind v3 + `[email protected]` ngày 2026-07-30, xem lý do quyết định trong `../processes/asset-license-management.md`. Không còn `tailwind.config.ts` — theme khai báo thẳng trong CSS qua `@theme inline { ... }` (`src/app/globals.css`), `postcss.config.js` chỉ còn plugin `@tailwindcss/postcss` (không cần `autoprefixer` riêng nữa). shadcn config: style `new-york-v4`, base color `neutral`, CSS variables bật, `tailwind.config: ""` (`components.json`) — đây là style/preset cổ điển (Radix), **không phải** 8 preset thương hiệu hoá mới của CLI hiện tại (`nova/vega/maia/lyra/mira/luma/sera/rhea`, chọn qua `-p`), vốn ép dùng theme "Nova/Vega..." + tuỳ chọn thư viện primitive khác (`--base base` = Base UI, `--base aria` = React Aria) — không dùng các preset đó cho project này. Component dùng `radix-ui` (package hợp nhất, không phải `@radix-ui/react-slot` riêng lẻ nữa) và `tw-animate-css` (thay `tailwindcss-animate`). `globals.css` còn `@import "shadcn/tailwind.css"` — 1 file CSS runtime do chính package `shadcn` (npm) publish kèm nhiều utility (data-state variants, scroll-fade, shimmer...), là dependency thật (không phải chỉ dev tool) vì cần resolve lúc build. Khi thêm component mới: `npx shadcn@latest add <component>` (đã test non-interactive OK với `components.json` này qua flag `-y --overwrite`).
|
||||
- Self-hosted (theo nguyên tắc chung của workspace — ưu tiên self-host hơn cloud, xem `../processes/new-office-fitout.md`)
|
||||
|
||||
## Coding conventions
|
||||
|
||||
Rút ra từ pattern thật đã dùng cho `Request` (xem `src/lib/requests.ts`, `src/app/api/requests/route.ts`, `prisma/schema.prisma`) — theo đúng pattern này cho các entity tiếp theo, đừng bịa pattern mới:
|
||||
|
||||
- **1 file schema Prisma** (`prisma/schema.prisma`) cho tất cả model — không tách nhiều file. Field trong model dùng camelCase (`employeeId`), map sang cột DB snake_case qua `@map("employee_id")`; tên bảng map qua `@@map("requests")` (snake_case, số nhiều).
|
||||
- **1 file `src/lib/<entity>.ts` mỗi entity**, chứa cả 3 thứ trong cùng file: Zod schema + hàm validate trả về `ValidationResult` (`{ok:true,value}|{ok:false,error}`, không throw), các hàm CRUD gọi Prisma Client, và 1 hàm `toRecord()` map từ shape Prisma (camelCase) sang shape public API (snake_case — vd `employee_id`, `created_at`). Ranh giới cố ý: nội bộ TS/Prisma dùng camelCase, API trả ra ngoài dùng snake_case.
|
||||
- **API route** (`src/app/api/<entity>/route.ts`) giữ mỏng: parse JSON trong try/catch (400 nếu invalid JSON), check `typeof body === "object" && body !== null` (400 nếu không), gọi hàm validate của lib, gọi hàm CRUD, trả `NextResponse.json(...)` với status code đúng (400 validation lỗi, 201 tạo mới, 200 list). Không viết business logic trong route handler.
|
||||
- **Prisma Client**: dùng singleton ở `src/lib/prisma.ts` (global-guard chống hot-reload spawn nhiều instance) — import `prisma` từ đó, không tự `new PrismaClient()` ở nơi khác.
|
||||
- **Enum có tập giá trị cố định gắn với domain** (vd `type: asset|license`) → Prisma `enum`, không phải `String` + check tay.
|
||||
- **Màu/theme Tailwind**: từ khi lên Tailwind v4 (2026-07-30), theme khai báo thẳng trong `src/app/globals.css` qua `@theme inline { --color-*: var(--*); ... }` — không còn `tailwind.config.ts`. Biến CSS ở `:root`/`.dark` đã là giá trị `oklch(...)` đầy đủ, `@theme inline` chỉ alias thẳng qua `var(--x)`, **không bọc thêm** `hsl(...)`/`rgb(...)` (bài học từ bug `hsl(oklch(...))` hồi còn Tailwind v3 + `tailwind.config.ts`, đã fix rồi bỏ luôn file đó khi lên v4).
|
||||
- **Test**: 1 file `tests/<entity>.test.ts` mirror theo lib file, chạy trên Postgres thật qua DB tạm per-test-file (xem `tests/setup.ts`) — không mock DB. `beforeEach` dọn bằng `prisma.<model>.deleteMany()`.
|
||||
- Schema mới/đổi schema → tạo migration Prisma thật (`prisma migrate dev --name <mô_tả>`), không sửa tay SQL.
|
||||
|
||||
## Môi trường
|
||||
|
||||
- Host Windows này **không có Node.js/npm cài native**. Mọi `npm install`/`npm test`/`tsc` phải chạy qua Docker.
|
||||
- DB là Postgres (không còn SQLite — xem quyết định trong `../processes/asset-license-management.md`), chạy qua `docker-compose.yml` (service `db`, network `asset-license-mgmt-net`, image `postgres:16-alpine`). Trước khi `npm test`, phải start Postgres:
|
||||
1. `docker compose up -d db` (từ thư mục project) — khởi động Postgres local dev.
|
||||
2. Chạy npm trong container gắn cùng network để resolve được hostname `db`:
|
||||
`docker run --rm --network asset-license-mgmt-net -v "<project dir>:/app" -w /app -e DATABASE_URL="postgres://asset_license_mgmt:change-me-local-dev@db:5432/asset_license_mgmt" node:20 sh -c "npm install && npm test"`
|
||||
3. Test tự tạo/xoá một database Postgres tạm (`test_<uuid>`) riêng cho mỗi lần chạy file test (xem `tests/setup.ts`), không đụng vào database dev chính.
|
||||
- `DATABASE_URL` mẫu nằm ở `.env.example` — copy thành `.env` (đã gitignore) nếu cần chỉnh local.
|
||||
- Docker Desktop đã confirm hoạt động được trên máy này (dùng để chạy Xibo containers, xem `../xibo/`).
|
||||
- `npm install` tự chạy `prisma generate` qua hook `postinstall` (xem `package.json`) — không cần gọi tay trừ khi thay đổi `schema.prisma` mà không chạy `npm install` lại.
|
||||
- `pg` vẫn còn trong `devDependencies` — **chỉ** dùng cho hạ tầng test (`tests/setup.ts` tạo/xoá throwaway DB), không phải data access layer của app (đó là Prisma). Đừng nhầm là app vẫn dùng raw SQL.
|
||||
|
||||
### Lưu ý về việc gọi custom agent (feature-implementer/test-runner/code-reviewer)
|
||||
|
||||
Các agent định nghĩa ở `.claude/agents/` trong thư mục **này** (`asset-license-mgmt/`) chỉ được Claude Code nhận diện làm `subagent_type` nếu phiên Claude Code được mở với working directory **là chính thư mục này** (hoặc thư mục con của nó) — Claude Code quét `.claude/agents/` từ cwd đi lên tới repo root, **không quét xuống** thư mục con. Nếu mở Claude Code từ `ubisoft/` (thư mục cha, gộp nhiều project), 3 agent này sẽ báo "not found". Cách đúng: `cd` vào `asset-license-mgmt/` rồi mới chạy `claude` khi làm việc trên project này.
|
||||
|
||||
## Multi-agent dev workflow
|
||||
|
||||
Mục tiêu: không để một agent vừa viết code vừa tự chấm bài mình — tách vai trò để bug bị bắt lại thay vì được tự "cho qua".
|
||||
|
||||
Với mỗi task cụ thể (vd "thêm API tạo Request", "màn hình bàn giao asset"):
|
||||
|
||||
1. **`feature-implementer`** — nhận đúng 1 task, viết code + test đi kèm trong cùng lượt. Không tự tuyên bố "đã pass" — chỉ báo cáo đã đổi gì, giả định gì, và cái gì cố tình để ngoài scope.
|
||||
2. **`test-runner`** — spawn **riêng**, không phải cùng agent vừa code. Chạy toàn bộ test suite + lint + type-check, báo lỗi cụ thể kèm file:line. Không tự sửa code.
|
||||
3. Nếu fail: đưa report của test-runner làm input cho một lượt `feature-implementer` mới để fix. Lặp lại bước 1–2 tới khi xanh hết.
|
||||
4. **`code-reviewer`** (optional, dùng cho thay đổi lớn/rủi ro — vd đổi state machine của Request, logic seat-count license) — review diff cuối cùng trước khi commit, tìm bug logic/security, không tự sửa.
|
||||
|
||||
### Quy tắc cứng
|
||||
- Implementer và test-runner luôn là 2 lượt spawn riêng biệt cho cùng 1 task — không gộp.
|
||||
- test-runner không được tự sửa file, kể cả khi lỗi trông "hiển nhiên".
|
||||
- Nếu implementer thấy task mơ hồ, chọn giả định nhỏ nhất hợp lý và ghi rõ trong report thay vì dừng lại hỏi — nhưng nếu ảnh hưởng luồng trạng thái Request/quyền approval thì phải hỏi trước khi code.
|
||||
|
||||
## Trạng thái hiện tại
|
||||
|
||||
- **Task 1 xong**: đã bootstrap Next.js (TS, App Router) project thật, entity `Request` (chỉ state `Draft`) với `POST/GET /api/requests`.
|
||||
- **Migrate SQLite → Postgres xong**: CONFIRMED pass qua test-runner độc lập.
|
||||
- **ESLint setup xong**: CONFIRMED pass sạch qua test-runner độc lập.
|
||||
- **Retrofit `Request` sang Prisma + Zod xong (2026-07-30)**: `prisma/schema.prisma` (model `Request`, enum `RequestType`), `src/lib/prisma.ts` (singleton client), `src/lib/requests.ts` (Zod schema thay hàm validate tay, Prisma Client thay raw SQL), `scripts/migrate.ts` (gọi `prisma migrate deploy`), `tests/setup.ts`/`tests/requests.test.ts` (schema áp qua `prisma migrate deploy`, dọn dữ liệu qua `prisma.request.deleteMany()`). `src/lib/db.ts` (raw pg Pool cũ) đã xoá. **CONFIRMED qua test-runner độc lập**: 6/6 test pass trên Postgres thật, type-check sạch, lint sạch. Migration SQL đối chiếu khớp 1:1 với schema cũ (chỉ khác `type` từ `TEXT+CHECK` sang Postgres enum — tương đương semantics).
|
||||
- `npm audit`: 1 "critical" nằm ở `vitest` (devDependency, CVE cần `vitest --ui` đang chạy — project không dùng UI mode nên không khai thác được trong cách dùng hiện tại). Rủi ro thực tế thấp, chưa cần fix gấp — xem "Việc còn dở".
|
||||
- **Tailwind + shadcn/ui setup xong (2026-07-30, ban đầu trên v3)**: `postcss.config.js`, `src/app/globals.css`, `components.json`, `src/lib/utils.ts` (`cn()`), `src/components/ui/button.tsx` (component mẫu để xác nhận pipeline chạy được). Có 1 bug phát hiện qua verify độc lập (màu token bị bọc `hsl()` chồng lên giá trị `oklch()` sẵn có → CSS không hợp lệ, silent fail lúc render dù build/lint/tsc đều xanh) — đã fix và CONFIRMED qua verify độc lập lần 2.
|
||||
- **Nâng cấp lên Tailwind v4 + shadcn CLI `@latest` xong (2026-07-30)**: bỏ `tailwind.config.ts` (theme giờ khai trong `globals.css` qua `@theme inline`), đổi `postcss.config.js` sang `@tailwindcss/postcss`, đổi dependency (`radix-ui` thay `@radix-ui/react-slot`, `tw-animate-css` thay `tailwindcss-animate`, thêm `shadcn` làm runtime dependency vì `globals.css` import `shadcn/tailwind.css`), `button.tsx` viết lại theo registry `new-york-v4` hiện tại. **CONFIRMED qua test-runner độc lập**: install/build/tsc/lint sạch, 6/6 test Request vẫn pass (không liên quan tới UI), CSS đã compile xác nhận màu sạch (không còn kiểu bug `hsl(oklch(...))`), không còn gói v3 sót lại.
|
||||
- **Rủi ro tồn đọng (dormant, chưa gãy)**: `button.tsx` mới bỏ `React.forwardRef` (convention của registry hiện tại là cho React 19), nhưng project đang pin **React 18.3.1** — nếu sau này có chỗ truyền `ref` vào `<Button>`, ref sẽ không được forward đúng cách. Hiện tại không chỗ nào trong code truyền `ref` nên chưa phát sinh lỗi — nhưng cần nhớ khi build UI thật, tránh `<Button ref={...}>` hoặc phải tự thêm lại `forwardRef` nếu cần.
|
||||
|
||||
## Việc còn dở
|
||||
|
||||
- Bump `vitest` lên 4.x (fix advisory critical GHSA-5xrq-8626-4rwp — hiện không khai thác được vì project không chạy `vitest --ui`, nhưng nên dọn khi có breaking-change window) — không khẩn cấp.
|
||||
- Rà lại advisory riêng của `next` (prod dependency, có vài high/moderate — vd SSRF qua WebSocket upgrade, middleware bypass) khi có dịp, tách biệt khỏi đợt retrofit Prisma/Zod này.
|
||||
- Test coverage gaps (chưa block gì, chỉ là follow-up): chưa test malformed JSON body, chưa test JSON body không phải object (array/string).
|
||||
- Chưa có git repo cho project này — chưa có version history/rollback cho bất kỳ thay đổi nào ở trên. Nên `git init` sớm trước khi làm thêm entity mới.
|
||||
- `Button` (shadcn) không `forwardRef`, React đang pin 18 — dormant risk, xem note ở "Trạng thái hiện tại".
|
||||
- Các entity còn lại: `Employee`, `AssetItem`, `License`, `PurchaseOrder`, `Receipt`, `Assignment`
|
||||
- Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled
|
||||
- UI (hiện chỉ có placeholder home page)
|
||||
- Auth/phân quyền (ai được approve request, ai được xác nhận nhận hàng/bàn giao)
|
||||
|
||||
---
|
||||
Cập nhật lần cuối: 2026-07-30
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york-v4",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: asset-license-mgmt-db
|
||||
volumes:
|
||||
- asset-license-mgmt-db-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_DB=asset_license_mgmt
|
||||
- POSTGRES_USER=asset_license_mgmt
|
||||
- POSTGRES_PASSWORD=change-me-local-dev
|
||||
# No host port published (same convention as xibo's cms-db) — this host
|
||||
# already runs other projects' Postgres containers on 5432. Reach this
|
||||
# service from other containers via the `asset-license-mgmt-net`
|
||||
# network and the `db` hostname instead (see .env.example / CLAUDE.md).
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- asset-license-mgmt
|
||||
|
||||
networks:
|
||||
asset-license-mgmt:
|
||||
name: asset-license-mgmt-net
|
||||
|
||||
volumes:
|
||||
asset-license-mgmt-db-data:
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
module.exports = nextConfig;
|
||||
Generated
+13049
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "asset-license-mgmt",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"postinstall": "prisma generate",
|
||||
"db:migrate": "tsx scripts/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.28.0",
|
||||
"next": "14.2.35",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"shadcn": "^4.16.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20.14.15",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "14.2.35",
|
||||
"pg": "^8.11.0",
|
||||
"postcss": "^8.5.25",
|
||||
"prisma": "^5.20.0",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.16.5",
|
||||
"typescript": "^5.5.4",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RequestType" AS ENUM ('asset', 'license');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "requests" (
|
||||
"id" UUID NOT NULL,
|
||||
"employee_id" TEXT NOT NULL,
|
||||
"type" "RequestType" NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'Draft',
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "requests_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,40 @@
|
||||
// Prisma schema — source of truth for the DB schema going forward,
|
||||
// replacing the hand-written `CREATE TABLE IF NOT EXISTS` migration that
|
||||
// used to live in src/lib/db.ts. Only the Request entity (Draft state only)
|
||||
// exists so far — see ../processes/asset-license-management.md for the full
|
||||
// data model / state machine this will grow into.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// Fixed, closed set of 2 values tied to the domain (asset vs license
|
||||
// request) — a native Postgres enum is the idiomatic Prisma mapping here.
|
||||
// This replaces the old `type TEXT NOT NULL CHECK (type IN ('asset',
|
||||
// 'license'))` column with `type "RequestType" NOT NULL` backed by a
|
||||
// `CREATE TYPE "RequestType" AS ENUM ('asset', 'license')`. Same validation
|
||||
// guarantee (only these two values are ever storable), different mechanism.
|
||||
// See feature-implementer report for the enum-vs-TEXT+CHECK trade-off.
|
||||
enum RequestType {
|
||||
asset
|
||||
license
|
||||
}
|
||||
|
||||
model Request {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employeeId String @map("employee_id")
|
||||
type RequestType
|
||||
reason String
|
||||
// Only the 'Draft' state is implemented so far. Deliberately kept as a
|
||||
// plain string (not an enum) until more states exist — see
|
||||
// src/lib/requests.ts's RequestStatus type.
|
||||
status String @default("Draft")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("requests")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Standalone init/migration entry point: `npm run db:migrate`.
|
||||
// Prisma Migrate (prisma/migrations) is now the source of truth for the
|
||||
// schema — this just delegates to `prisma migrate deploy`, which applies
|
||||
// any pending migrations against DATABASE_URL. Replaces the old
|
||||
// src/lib/db.ts's manual `CREATE TABLE IF NOT EXISTS` runner.
|
||||
// Requires DATABASE_URL to point at a reachable Postgres (see
|
||||
// docker-compose.yml / .env.example).
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
function main(): void {
|
||||
execSync("npx prisma migrate deploy", { stdio: "inherit" });
|
||||
const { host, pathname } = new URL(process.env.DATABASE_URL as string);
|
||||
console.log(`Migrations applied. Database ready at ${host}${pathname}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createRequest, listRequests, validateCreateRequestInput } from "@/lib/requests";
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
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 { employee_id, type, reason } = body as Record<string, unknown>;
|
||||
const validation = validateCreateRequestInput({ employee_id, type, reason });
|
||||
|
||||
if (!validation.ok) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const created = await createRequest(validation.value);
|
||||
return NextResponse.json(created, { status: 201 });
|
||||
}
|
||||
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
const requests = await listRequests();
|
||||
return NextResponse.json(requests, { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata = {
|
||||
title: "Asset & License Management",
|
||||
description: "Internal IT asset & license management — Ubisoft VN local IT",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
|
||||
<h1 className="text-2xl font-semibold">Asset & License Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Scaffold in progress. See CLAUDE.md for the multi-agent dev workflow.
|
||||
</p>
|
||||
<Button>Tailwind + shadcn/ui wired up</Button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
// Standard Next.js dev-mode singleton guard: hot-reloading re-evaluates this
|
||||
// module on every edit, which would otherwise spawn a fresh PrismaClient
|
||||
// (and a fresh connection pool) on every reload. Stashing the instance on
|
||||
// `globalThis` lets it survive across reloads in dev; production and test
|
||||
// runs each just get one instance per process, which is what we want there
|
||||
// too.
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { z } from "zod";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
export type RequestType = "asset" | "license";
|
||||
|
||||
// Only the 'Draft' state is implemented in this task. Later stages
|
||||
// (Pending Approval, Approved, Ordered, Received, Delivered, Closed,
|
||||
// Rejected/Cancelled — see processes/asset-license-management.md) are
|
||||
// deliberately out of scope.
|
||||
export type RequestStatus = "Draft";
|
||||
|
||||
export interface RequestRecord {
|
||||
id: string;
|
||||
employee_id: string;
|
||||
type: RequestType;
|
||||
reason: string;
|
||||
status: RequestStatus;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateRequestInput {
|
||||
employee_id: string;
|
||||
type: RequestType;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
| { ok: true; value: CreateRequestInput }
|
||||
| { ok: false; error: string };
|
||||
|
||||
// A string field that must be present and not just whitespace. Mirrors the
|
||||
// old hand-written `typeof x !== "string" || x.trim() === ""` check: the
|
||||
// value itself is NOT trimmed/transformed, only validated — the raw input is
|
||||
// still what gets stored.
|
||||
function nonEmptyString(message: string) {
|
||||
return z.string({ required_error: message, invalid_type_error: message }).refine(
|
||||
(value) => value.trim() !== "",
|
||||
{ message }
|
||||
);
|
||||
}
|
||||
|
||||
const createRequestSchema = z.object({
|
||||
employee_id: nonEmptyString("employee_id is required and must be a non-empty string"),
|
||||
reason: nonEmptyString("reason is required and must be a non-empty string"),
|
||||
type: z.enum(["asset", "license"], {
|
||||
errorMap: () => ({
|
||||
message: "type is required and must be exactly 'asset' or 'license'",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates raw (untyped) request-creation input.
|
||||
* employee_id and reason must be non-empty strings; type must be exactly
|
||||
* 'asset' or 'license'.
|
||||
*/
|
||||
export function validateCreateRequestInput(input: {
|
||||
employee_id?: unknown;
|
||||
type?: unknown;
|
||||
reason?: unknown;
|
||||
}): ValidationResult {
|
||||
const result = createRequestSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
const message = result.error.issues[0]?.message ?? "Invalid input";
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
return { ok: true, value: result.data };
|
||||
}
|
||||
|
||||
function toRecord(row: {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
type: RequestType;
|
||||
reason: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
}): RequestRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
employee_id: row.employeeId,
|
||||
type: row.type,
|
||||
reason: row.reason,
|
||||
status: row.status as RequestStatus,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRequest(input: CreateRequestInput): Promise<RequestRecord> {
|
||||
const created = await prisma.request.create({
|
||||
data: {
|
||||
employeeId: input.employee_id,
|
||||
type: input.type,
|
||||
reason: input.reason,
|
||||
},
|
||||
});
|
||||
return toRecord(created);
|
||||
}
|
||||
|
||||
export async function listRequests(): Promise<RequestRecord[]> {
|
||||
const rows = await prisma.request.findMany({
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
});
|
||||
return rows.map(toRecord);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { GET, POST } from "@/app/api/requests/route";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function postRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/api/requests", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Each test starts from an empty table so listing/order assertions are
|
||||
// deterministic.
|
||||
await prisma.request.deleteMany();
|
||||
});
|
||||
|
||||
describe("POST /api/requests", () => {
|
||||
it("creates a request in Draft status on valid input", async () => {
|
||||
const res = await POST(
|
||||
postRequest({ employee_id: "emp-1", type: "asset", reason: "New laptop for onboarding" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body).toMatchObject({
|
||||
employee_id: "emp-1",
|
||||
type: "asset",
|
||||
reason: "New laptop for onboarding",
|
||||
status: "Draft",
|
||||
});
|
||||
expect(typeof body.id).toBe("string");
|
||||
expect(body.id.length).toBeGreaterThan(0);
|
||||
expect(typeof body.created_at).toBe("string");
|
||||
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects requests with missing required fields", async () => {
|
||||
const res = await POST(postRequest({ employee_id: "", type: "asset", reason: "" }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(body.error.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects requests missing the reason field entirely", async () => {
|
||||
const res = await POST(postRequest({ employee_id: "emp-1", type: "asset" }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(typeof body.error).toBe("string");
|
||||
});
|
||||
|
||||
it("rejects an invalid type value", async () => {
|
||||
const res = await POST(
|
||||
postRequest({ employee_id: "emp-1", type: "not-a-real-type", reason: "Something" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/type/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/requests", () => {
|
||||
it("lists created requests, newest first", async () => {
|
||||
await POST(postRequest({ employee_id: "emp-1", type: "asset", reason: "First request" }));
|
||||
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await POST(postRequest({ employee_id: "emp-2", type: "license", reason: "Second request" }));
|
||||
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body).toHaveLength(2);
|
||||
|
||||
// newest first
|
||||
expect(body[0]).toMatchObject({ employee_id: "emp-2", type: "license", status: "Draft" });
|
||||
expect(body[1]).toMatchObject({ employee_id: "emp-1", type: "asset", status: "Draft" });
|
||||
});
|
||||
|
||||
it("returns an empty list when there are no requests", async () => {
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// Creates a throwaway Postgres database per test-file run against the
|
||||
// docker-compose `db` service, points DATABASE_URL at it so tests never
|
||||
// touch a shared dev database, and drops it afterwards. Mirrors the old
|
||||
// sqlite temp-file-per-run isolation, adapted to Postgres (CREATE/DROP
|
||||
// DATABASE instead of a throwaway file).
|
||||
//
|
||||
// Creating/dropping the throwaway database itself stays raw `pg` (test
|
||||
// infra, not the app's data-access layer) — but applying the schema to the
|
||||
// fresh database, and tearing down the pooled connection afterwards, now
|
||||
// goes through Prisma instead of the old db.ts.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execSync } from "node:child_process";
|
||||
import { Client } from "pg";
|
||||
import { afterAll, beforeAll } from "vitest";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const baseUrl = new URL(
|
||||
process.env.DATABASE_URL ||
|
||||
"postgres://asset_license_mgmt:change-me-local-dev@db:5432/asset_license_mgmt"
|
||||
);
|
||||
const testDbName = `test_${randomUUID().replace(/-/g, "_")}`;
|
||||
|
||||
function adminUrl(): string {
|
||||
const url = new URL(baseUrl.toString());
|
||||
url.pathname = "/postgres";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function withAdminClient<T>(fn: (client: Client) => Promise<T>): Promise<T> {
|
||||
const client = new Client({ connectionString: adminUrl() });
|
||||
await client.connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await withAdminClient((client) => client.query(`CREATE DATABASE ${testDbName}`));
|
||||
|
||||
const testUrl = new URL(baseUrl.toString());
|
||||
testUrl.pathname = `/${testDbName}`;
|
||||
process.env.DATABASE_URL = testUrl.toString();
|
||||
|
||||
// Apply the Prisma migration history to the fresh throwaway database.
|
||||
// `migrate deploy` replays the same real migrations production uses
|
||||
// (prisma/migrations), rather than a schema-diffing shortcut like
|
||||
// `db push` — keeping this test setup honest about what actually runs
|
||||
// outside tests too.
|
||||
execSync("npx prisma migrate deploy", { stdio: "inherit" });
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
// Disconnect the app's Prisma Client singleton first so the admin
|
||||
// connection is free to drop the database (DROP DATABASE ... WITH
|
||||
// (FORCE) would also disconnect it, but closing cleanly first avoids
|
||||
// relying on that).
|
||||
await prisma.$disconnect();
|
||||
await withAdminClient((client) =>
|
||||
client.query(`DROP DATABASE IF EXISTS ${testDbName} WITH (FORCE)`)
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
setupFiles: ["./tests/setup.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user