From 69fa00012b3c2e37c1e8fa9311ed885871ddfe40 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 20:30:38 -0400 Subject: [PATCH 1/4] feat: add AssetCategory Prisma model + migration --- .../migration.sql | 12 ++++++++ prisma/schema.prisma | 29 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 prisma/migrations/20260803003026_add_asset_category/migration.sql diff --git a/prisma/migrations/20260803003026_add_asset_category/migration.sql b/prisma/migrations/20260803003026_add_asset_category/migration.sql new file mode 100644 index 0000000..e024140 --- /dev/null +++ b/prisma/migrations/20260803003026_add_asset_category/migration.sql @@ -0,0 +1,12 @@ +-- CreateEnum +CREATE TYPE "AssetCategoryValueGroup" AS ENUM ('high_value', 'low_value'); + +-- CreateTable +CREATE TABLE "asset_categories" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "value_group" "AssetCategoryValueGroup" NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "asset_categories_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 63c5938..4ef20bf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -216,6 +216,35 @@ model PurchaseOrder { @@map("purchase_orders") } +// Fixed, closed set of 2 values tied to the domain (see docs/ +// asset-license-management.md's "## Phân loại tài sản: High-value vs +// Low-value/Consumable" section) — native Postgres enum, same rationale as +// RequestType/AssetItemStatus/LicenseType above. Determines downstream +// rules for entities that will attach to a category later (not built in +// this task): AssetModel.categoryId must point to a `high_value` category, +// ConsumableItem.categoryId must point to a `low_value` category. +enum AssetCategoryValueGroup { + high_value + low_value +} + +// Asset classification catalog — see docs/asset-license-management.md's +// "## Phân loại tài sản" section and the `AssetCategory` row in the "Data +// model (sơ bộ)" table. Standalone entity, no FKs (same starting shape as +// Vendor before it grew relations) — `AssetModel` and +// `AssetCategoryAttributeDef` will attach to this via `categoryId` in +// later tasks (not this one). IT admin defines the concrete categories +// (e.g. "Laptop", "Cáp HDMI") freely; only `valueGroup` is a fixed 2-value +// set. +model AssetCategory { + id String @id @default(uuid()) @db.Uuid + name String + valueGroup AssetCategoryValueGroup @map("value_group") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@map("asset_categories") +} + // Minimal internal auth accounts — NOT company SSO (deliberately ruled out, // see CLAUDE.md / auth decision notes). A handful of trusted IT staff // accounts, created by hand via scripts/create-user.ts, no self-registration. From 20ec7a11df3b0968abb20354f51695861d076550 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 20:31:22 -0400 Subject: [PATCH 2/4] feat: add AssetCategory validation + CRUD (src/lib/asset-categories.ts) --- src/lib/asset-categories.ts | 107 ++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/lib/asset-categories.ts diff --git a/src/lib/asset-categories.ts b/src/lib/asset-categories.ts new file mode 100644 index 0000000..4cca1b5 --- /dev/null +++ b/src/lib/asset-categories.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; +import { prisma } from "./prisma"; + +export type AssetCategoryValueGroup = "high_value" | "low_value"; + +export interface AssetCategoryRecord { + id: string; + name: string; + value_group: AssetCategoryValueGroup; + created_at: string; +} + +export interface CreateAssetCategoryInput { + name: string; + valueGroup: AssetCategoryValueGroup; +} + +export type ValidationResult = + | { ok: true; value: CreateAssetCategoryInput } + | { ok: false; error: string }; + +// Mirrors src/lib/vendors.ts's nonEmptyString helper. +function nonEmptyString(message: string) { + return z.string({ required_error: message, invalid_type_error: message }).refine( + (value) => value.trim() !== "", + { message } + ); +} + +const createAssetCategorySchema = z.object({ + name: nonEmptyString("name is required and must be a non-empty string"), + // Fixed 2-value set (see docs/asset-license-management.md's "## Phân + // loại tài sản" section) — mirrors src/lib/licenses.ts's licenseType + // z.enum() pattern. + valueGroup: z.enum(["high_value", "low_value"], { + errorMap: () => ({ + message: "valueGroup is required and must be either 'high_value' or 'low_value'", + }), + }), +}); + +/** + * Validates raw (untyped) asset-category-creation input. name must be a + * non-empty string, valueGroup must be one of the two fixed enum values + * (per the "## Phân loại tài sản" spec in docs/asset-license-management.md). + */ +export function validateCreateAssetCategoryInput(input: { + name?: unknown; + valueGroup?: unknown; +}): ValidationResult { + const result = createAssetCategorySchema.safeParse(input); + if (!result.success) { + const message = result.error.issues[0]?.message ?? "Invalid input"; + return { ok: false, error: message }; + } + return { ok: true, value: result.data }; +} + +function toRecord(row: { + id: string; + name: string; + valueGroup: AssetCategoryValueGroup; + createdAt: Date; +}): AssetCategoryRecord { + return { + id: row.id, + name: row.name, + value_group: row.valueGroup, + created_at: row.createdAt.toISOString(), + }; +} + +export type CreateAssetCategoryResult = + | { ok: true; value: AssetCategoryRecord } + | { ok: false; error: string }; + +/** + * Creates an AssetCategory. No unique constraint on any field, no FKs + * either (see docs/asset-license-management.md's "## Phân loại tài sản" + * spec — standalone entity, same starting shape as Vendor before it grew + * relations) — so there's no known Prisma error code to catch/translate + * here today. Still returns the `{ok,value}|{ok,error}` shape (rather than + * a bare Promise like createVendor()) to match the + * convention used across the newer entities (AssetItem/License) — see the + * code-reviewer finding on createLicense() in CLAUDE.md's "Trạng thái hiện + * tại": adding this shape up front avoids a silent 500 if a unique + * constraint is added later (e.g. on `name`) without remembering to also + * add P2002 handling here. + */ +export async function createAssetCategory( + input: CreateAssetCategoryInput +): Promise { + const created = await prisma.assetCategory.create({ + data: { + name: input.name, + valueGroup: input.valueGroup, + }, + }); + return { ok: true, value: toRecord(created) }; +} + +export async function listAssetCategories(): Promise { + const rows = await prisma.assetCategory.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + return rows.map(toRecord); +} From 63eb3df33d4829310bc6826d4c2dd705fc78e3f8 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 20:31:27 -0400 Subject: [PATCH 3/4] feat: add AssetCategory API routes --- src/app/api/asset-categories/route.ts | 51 +++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/app/api/asset-categories/route.ts diff --git a/src/app/api/asset-categories/route.ts b/src/app/api/asset-categories/route.ts new file mode 100644 index 0000000..ac6461b --- /dev/null +++ b/src/app/api/asset-categories/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { + createAssetCategory, + listAssetCategories, + validateCreateAssetCategoryInput, +} from "@/lib/asset-categories"; + +// Mirrors src/app/api/licenses/route.ts's pattern — middleware.ts excludes +// /api from route protection, so this checks the session itself. Flat +// permission model (CLAUDE.md): only "logged in or not", no role check. +export async function POST(request: Request): Promise { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 }); + } + + if (typeof body !== "object" || body === null) { + return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); + } + + const { name, valueGroup } = body as Record; + const validation = validateCreateAssetCategoryInput({ name, valueGroup }); + + if (!validation.ok) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const created = await createAssetCategory(validation.value); + if (!created.ok) { + return NextResponse.json({ error: created.error }, { status: 400 }); + } + return NextResponse.json(created.value, { status: 201 }); +} + +export async function GET(): Promise { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const assetCategories = await listAssetCategories(); + return NextResponse.json(assetCategories, { status: 200 }); +} From aaab8fd9b797049d47e9090592f1fc0842531289 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 20:32:38 -0400 Subject: [PATCH 4/4] test: add AssetCategory API tests --- tests/asset-categories.test.ts | 306 +++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 tests/asset-categories.test.ts diff --git a/tests/asset-categories.test.ts b/tests/asset-categories.test.ts new file mode 100644 index 0000000..744c6f4 --- /dev/null +++ b/tests/asset-categories.test.ts @@ -0,0 +1,306 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Mock } from "vitest"; +import type { Session } from "next-auth"; +import { + createAssetCategory, + listAssetCategories, + validateCreateAssetCategoryInput, +} from "@/lib/asset-categories"; +import { prisma } from "@/lib/prisma"; + +// Note: this file has two layers of coverage, mirroring tests/vendors.test.ts: +// - lib-level tests below exercise src/lib/asset-categories.ts directly +// (validation, valueGroup enum, list ordering). +// - route-level tests (further down) exercise +// src/app/api/asset-categories/route.ts over HTTP, including the auth() +// gate — mirrors tests/vendors.test.ts's @/auth mocking pattern. +vi.mock("@/auth", () => ({ auth: vi.fn() })); + +import { GET, POST } from "@/app/api/asset-categories/route"; +import { auth } from "@/auth"; + +const mockedAuth = auth as unknown as Mock<() => Promise>; + +const testSession: Session = { + user: { id: "test-user-id" }, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), +}; + +function postRequest(body: unknown): Request { + return new Request("http://localhost/api/asset-categories", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(async () => { + // Each test starts from an empty table so listing/order assertions are + // deterministic. + await prisma.assetCategory.deleteMany(); + // Default to a logged-in session — the lib-level tests below don't go + // through the route at all (so this is a no-op for them), and the + // route-level tests exercise business logic, not auth gating (that's + // covered by the "auth gating" describe block further down). + mockedAuth.mockResolvedValue(testSession); +}); + +describe("validateCreateAssetCategoryInput", () => { + it("accepts a valid high_value category", () => { + const result = validateCreateAssetCategoryInput({ + name: "Laptop", + valueGroup: "high_value", + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toMatchObject({ name: "Laptop", valueGroup: "high_value" }); + } + }); + + it("accepts a valid low_value category", () => { + const result = validateCreateAssetCategoryInput({ + name: "Cáp HDMI", + valueGroup: "low_value", + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toMatchObject({ name: "Cáp HDMI", valueGroup: "low_value" }); + } + }); + + it("rejects an empty name", () => { + const result = validateCreateAssetCategoryInput({ + name: "", + valueGroup: "high_value", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/name/i); + } + }); + + it("rejects a missing name", () => { + const result = validateCreateAssetCategoryInput({ + valueGroup: "high_value", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/name/i); + } + }); + + it("rejects an invalid valueGroup", () => { + const result = validateCreateAssetCategoryInput({ + name: "Laptop", + valueGroup: "medium_value", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/valueGroup/i); + } + }); + + it("rejects a missing valueGroup", () => { + const result = validateCreateAssetCategoryInput({ + name: "Laptop", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/valueGroup/i); + } + }); +}); + +describe("createAssetCategory", () => { + it("creates a high_value category", async () => { + const result = validateCreateAssetCategoryInput({ + name: "Laptop", + valueGroup: "high_value", + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const created = await createAssetCategory(result.value); + + expect(created.ok).toBe(true); + if (!created.ok) return; + expect(created.value).toMatchObject({ name: "Laptop", value_group: "high_value" }); + expect(typeof created.value.id).toBe("string"); + expect(created.value.id.length).toBeGreaterThan(0); + expect(Number.isNaN(Date.parse(created.value.created_at))).toBe(false); + }); + + it("creates a low_value category", async () => { + const result = validateCreateAssetCategoryInput({ + name: "Cáp HDMI", + valueGroup: "low_value", + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const created = await createAssetCategory(result.value); + + expect(created.ok).toBe(true); + if (!created.ok) return; + expect(created.value).toMatchObject({ name: "Cáp HDMI", value_group: "low_value" }); + }); +}); + +describe("listAssetCategories", () => { + it("lists created categories, newest first", async () => { + const first = validateCreateAssetCategoryInput({ name: "Cáp HDMI", valueGroup: "low_value" }); + expect(first.ok).toBe(true); + if (first.ok) await createAssetCategory(first.value); + + // Ensure a distinct created_at ordering even on fast filesystems/clocks. + await new Promise((resolve) => setTimeout(resolve, 5)); + + const second = validateCreateAssetCategoryInput({ name: "Laptop", valueGroup: "high_value" }); + expect(second.ok).toBe(true); + if (second.ok) await createAssetCategory(second.value); + + const records = await listAssetCategories(); + + expect(records).toHaveLength(2); + // newest first + expect(records[0]).toMatchObject({ name: "Laptop" }); + expect(records[1]).toMatchObject({ name: "Cáp HDMI" }); + }); + + it("returns an empty list when there are no categories", async () => { + const records = await listAssetCategories(); + expect(records).toEqual([]); + }); +}); + +describe("POST /api/asset-categories", () => { + it("creates a high_value category on valid input", async () => { + const res = await POST(postRequest({ name: "Laptop", valueGroup: "high_value" })); + + expect(res.status).toBe(201); + const body = await res.json(); + + expect(body).toMatchObject({ name: "Laptop", value_group: "high_value" }); + expect(typeof body.id).toBe("string"); + expect(body.id.length).toBeGreaterThan(0); + expect(Number.isNaN(Date.parse(body.created_at))).toBe(false); + }); + + it("creates a low_value category on valid input", async () => { + const res = await POST(postRequest({ name: "Cáp HDMI", valueGroup: "low_value" })); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toMatchObject({ name: "Cáp HDMI", value_group: "low_value" }); + }); + + it("rejects an empty name", async () => { + const res = await POST(postRequest({ name: "", valueGroup: "high_value" })); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/name/i); + }); + + it("rejects an invalid valueGroup", async () => { + const res = await POST(postRequest({ name: "Laptop", valueGroup: "medium_value" })); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/valueGroup/i); + }); + + it("rejects a missing valueGroup", async () => { + const res = await POST(postRequest({ name: "Laptop" })); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/valueGroup/i); + }); + + it("rejects an invalid JSON body", async () => { + const req = new Request("http://localhost/api/asset-categories", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json", + }); + + const res = await POST(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/JSON/i); + }); + + it("rejects a non-object JSON body", async () => { + // Note: arrays are `typeof "object"` in JS, so they pass the route's + // object check (same behavior as /api/vendors) — use a JSON string + // primitive here to actually exercise the "not an object" branch. + const res = await POST(postRequest("not an object")); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/JSON object/i); + }); +}); + +describe("GET /api/asset-categories", () => { + it("lists created categories, newest first", async () => { + await POST(postRequest({ name: "Cáp HDMI", valueGroup: "low_value" })); + // Ensure a distinct created_at ordering even on fast filesystems/clocks. + await new Promise((resolve) => setTimeout(resolve, 5)); + await POST(postRequest({ name: "Laptop", valueGroup: "high_value" })); + + const res = await GET(); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body).toHaveLength(2); + + // newest first + expect(body[0]).toMatchObject({ name: "Laptop" }); + expect(body[1]).toMatchObject({ name: "Cáp HDMI" }); + }); + + it("returns an empty list when there are no categories", async () => { + const res = await GET(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual([]); + }); +}); + +describe("auth gating on /api/asset-categories", () => { + // Mirrors tests/vendors.test.ts's "auth gating" block — only checks + // session presence, per the flat permission model (no role/permission + // logic). + it("POST /api/asset-categories returns 401 without a session", async () => { + mockedAuth.mockResolvedValue(null); + + const res = await POST(postRequest({ name: "Laptop", valueGroup: "high_value" })); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body).toEqual({ error: "Unauthorized" }); + + // And it must not have created anything. + const remaining = await prisma.assetCategory.findMany(); + expect(remaining).toHaveLength(0); + }); + + it("GET /api/asset-categories returns 401 without a session", async () => { + mockedAuth.mockResolvedValue(null); + + const res = await GET(); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body).toEqual({ error: "Unauthorized" }); + }); +});