From 123cbce2bc099ffa10889b2e9ef1b64527c26e5d Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 20:38:41 -0400 Subject: [PATCH] feat: add AssetCategoryAttributeDef validation + CRUD (src/lib/asset-category-attribute-defs.ts) Zod schema + validate function, Prisma-backed CRUD following the {ok,value}|{ok,error} pattern from the start. createAssetCategoryAttributeDef() catches P2002 for the composite (categoryId, key) unique constraint (mirrors createAssetItem()'s serialNumber handling) and P2003 for the categoryId FK (mirrors createPurchaseOrder()). --- src/lib/asset-category-attribute-defs.ts | 169 +++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 src/lib/asset-category-attribute-defs.ts diff --git a/src/lib/asset-category-attribute-defs.ts b/src/lib/asset-category-attribute-defs.ts new file mode 100644 index 0000000..fce942d --- /dev/null +++ b/src/lib/asset-category-attribute-defs.ts @@ -0,0 +1,169 @@ +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { prisma } from "./prisma"; + +export type AssetAttributeDataType = "string" | "number" | "boolean" | "date"; + +export interface AssetCategoryAttributeDefRecord { + id: string; + category_id: string; + key: string; + label: string; + data_type: AssetAttributeDataType; + required: boolean; + sort_order: number; + created_at: string; +} + +export interface CreateAssetCategoryAttributeDefInput { + categoryId: string; + key: string; + label: string; + dataType: AssetAttributeDataType; + required: boolean; + sortOrder: number; +} + +export type ValidationResult = + | { ok: true; value: CreateAssetCategoryAttributeDefInput } + | { ok: false; error: string }; + +// Mirrors src/lib/asset-categories.ts's nonEmptyString helper. +function nonEmptyString(message: string) { + return z.string({ required_error: message, invalid_type_error: message }).refine( + (value) => value.trim() !== "", + { message } + ); +} + +const createAssetCategoryAttributeDefSchema = z.object({ + // FK to AssetCategory — only a UUID *format* check here, whether it + // references a real category is left to Prisma's own FK constraint at + // insert time (see createAssetCategoryAttributeDef()'s P2003 handling + // below). Mirrors src/lib/purchase-orders.ts's vendorId handling. + categoryId: z + .string({ required_error: "categoryId is required and must be a valid UUID string" }) + .uuid("categoryId is required and must be a valid UUID string"), + // Slug-style, e.g. "ram_gb" — only unique *within* a category (composite + // unique (categoryId, key) at the DB layer), not globally. Just requires + // non-empty here; the DB-level uniqueness is enforced by + // createAssetCategoryAttributeDef()'s P2002 handling below. + key: nonEmptyString("key is required and must be a non-empty string"), + label: nonEmptyString("label is required and must be a non-empty string"), + // Fixed 4-value set (see docs/asset-license-management.md's "Cập nhật + // 2026-08-02 (3)" section) — a DEV-defined closed technical set, mirrors + // src/lib/asset-categories.ts's valueGroup z.enum() pattern. + dataType: z.enum(["string", "number", "boolean", "date"], { + errorMap: () => ({ + message: "dataType is required and must be one of 'string', 'number', 'boolean', 'date'", + }), + }), + required: z.boolean({ + required_error: "required is required and must be a boolean", + invalid_type_error: "required is required and must be a boolean", + }), + sortOrder: z.number({ + required_error: "sortOrder is required and must be an integer", + invalid_type_error: "sortOrder is required and must be an integer", + }) + .int("sortOrder is required and must be an integer"), +}); + +/** + * Validates raw (untyped) asset-category-attribute-def-creation input. + * categoryId must be a valid UUID string, key/label must be non-empty + * strings, dataType must be one of the four fixed enum values, required + * must be a boolean, sortOrder must be an integer. Whether categoryId + * references a real AssetCategory, and whether (categoryId, key) is + * actually unique, are both left to Prisma's constraints at insert time + * (see createAssetCategoryAttributeDef() below). + */ +export function validateCreateAssetCategoryAttributeDefInput(input: { + categoryId?: unknown; + key?: unknown; + label?: unknown; + dataType?: unknown; + required?: unknown; + sortOrder?: unknown; +}): ValidationResult { + const result = createAssetCategoryAttributeDefSchema.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; + categoryId: string; + key: string; + label: string; + dataType: AssetAttributeDataType; + required: boolean; + sortOrder: number; + createdAt: Date; +}): AssetCategoryAttributeDefRecord { + return { + id: row.id, + category_id: row.categoryId, + key: row.key, + label: row.label, + data_type: row.dataType, + required: row.required, + sort_order: row.sortOrder, + created_at: row.createdAt.toISOString(), + }; +} + +export type CreateAssetCategoryAttributeDefResult = + | { ok: true; value: AssetCategoryAttributeDefRecord } + | { ok: false; error: string }; + +/** + * Creates an AssetCategoryAttributeDef. If (categoryId, key) collides with + * an existing row, Prisma raises P2002 (composite unique constraint + * violation) — caught here and translated into a clean {ok:false,error}, + * same pattern as createAssetItem()'s serialNumber P2002 handling in + * src/lib/assets.ts. Note this is a *composite* uniqueness (unique within a + * category, not globally) — the same `key` under a *different* categoryId + * is expected to succeed, per docs/asset-license-management.md's "Cập nhật + * 2026-08-02 (3)" section. If categoryId doesn't reference an existing + * AssetCategory, Prisma raises P2003 (FK constraint violation) — caught the + * same way, mirrors src/lib/purchase-orders.ts's createPurchaseOrder() P2003 + * handling. + */ +export async function createAssetCategoryAttributeDef( + input: CreateAssetCategoryAttributeDefInput +): Promise { + try { + const created = await prisma.assetCategoryAttributeDef.create({ + data: { + categoryId: input.categoryId, + key: input.key, + label: input.label, + dataType: input.dataType, + required: input.required, + sortOrder: input.sortOrder, + }, + }); + return { ok: true, value: toRecord(created) }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + return { ok: false, error: "key already exists for this category" }; + } + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { + return { ok: false, error: "categoryId does not reference an existing asset category" }; + } + throw error; + } +} + +export async function listAssetCategoryAttributeDefs(): Promise< + AssetCategoryAttributeDefRecord[] +> { + const rows = await prisma.assetCategoryAttributeDef.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + return rows.map(toRecord); +}