commit afdbdbdcfb4dcc67a18d2bb4fd78b883e8dc7e01 Author: Toan Hoang Minh Date: Thu Jul 30 18:14:36 2026 +0700 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/ diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 0000000..d9a00a4 --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -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. diff --git a/.claude/agents/feature-implementer.md b/.claude/agents/feature-implementer.md new file mode 100644 index 0000000..520ad0f --- /dev/null +++ b/.claude/agents/feature-implementer.md @@ -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). diff --git a/.claude/agents/test-runner.md b/.claude/agents/test-runner.md new file mode 100644 index 0000000..0a8d51c --- /dev/null +++ b/.claude/agents/test-runner.md @@ -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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e76011e --- /dev/null +++ b/.env.example @@ -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 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3307be3 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f3c8acb --- /dev/null +++ b/CLAUDE.md @@ -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 + `shadcn@2.10.0` 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 ` (đã 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/.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//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/.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..deleteMany()`. +- Schema mới/đổi schema → tạo migration Prisma thật (`prisma migrate dev --name `), 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 ":/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_`) 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 ` + + ); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..4d38506 --- /dev/null +++ b/src/components/ui/button.tsx @@ -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 & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..ba3c933 --- /dev/null +++ b/src/lib/prisma.ts @@ -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; +} diff --git a/src/lib/requests.ts b/src/lib/requests.ts new file mode 100644 index 0000000..67324b0 --- /dev/null +++ b/src/lib/requests.ts @@ -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 { + 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 { + const rows = await prisma.request.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + return rows.map(toRecord); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/tests/requests.test.ts b/tests/requests.test.ts new file mode 100644 index 0000000..983e49e --- /dev/null +++ b/tests/requests.test.ts @@ -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([]); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..42b6432 --- /dev/null +++ b/tests/setup.ts @@ -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(fn: (client: Client) => Promise): Promise { + 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)`) + ); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b8cdcc7 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ad09a1b --- /dev/null +++ b/vitest.config.ts @@ -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"], + }, +});