From 1c530a316224cc5fc466a56927c911b2021af92d Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 20:48:09 -0400 Subject: [PATCH] feat: add AssetModel validation + CRUD (src/lib/asset-models.ts) --- src/lib/asset-models.ts | 153 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/lib/asset-models.ts diff --git a/src/lib/asset-models.ts b/src/lib/asset-models.ts new file mode 100644 index 0000000..5b5e2e0 --- /dev/null +++ b/src/lib/asset-models.ts @@ -0,0 +1,153 @@ +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { prisma } from "./prisma"; + +export interface AssetModelRecord { + id: string; + category_id: string; + manufacturer: string; + name: string; + specs: Record; + created_at: string; +} + +export interface CreateAssetModelInput { + categoryId: string; + manufacturer: string; + name: string; + specs: Record; +} + +export type ValidationResult = + | { ok: true; value: CreateAssetModelInput } + | { ok: false; error: string }; + +// Mirrors src/lib/asset-category-attribute-defs.ts's nonEmptyString helper. +function nonEmptyString(message: string) { + return z.string({ required_error: message, invalid_type_error: message }).refine( + (value) => value.trim() !== "", + { message } + ); +} + +const createAssetModelSchema = z.object({ + // FK to AssetCategory — only a UUID *format* check here, whether it + // references a real category (and whether that category is + // valueGroup=high_value) is left to createAssetModel()'s explicit lookup + // below — that's a business rule, not something pure Zod schema + // validation on this input alone can express. Mirrors + // src/lib/asset-category-attribute-defs.ts's categoryId 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"), + manufacturer: nonEmptyString("manufacturer is required and must be a non-empty string"), + name: nonEmptyString("name is required and must be a non-empty string"), + // Deliberately LOOSE for this task — just "a plain JSON object" (no + // arrays, no primitives at the top level). Does NOT validate keys/types + // dynamically against this model's category's AssetCategoryAttributeDef + // rows — that's a separate follow-up task per docs/ + // asset-license-management.md's "Cập nhật 2026-08-02 (3)" section, out of + // scope here. Defaults to {} when omitted, matching the DB column default. + specs: z + .record(z.unknown(), { + invalid_type_error: "specs must be a JSON object", + }) + .optional() + .default({}), +}); + +/** + * Validates raw (untyped) asset-model-creation input. categoryId must be a + * valid UUID string, manufacturer/name must be non-empty strings, specs + * must be a plain JSON object (or omitted, defaulting to {}). Whether + * categoryId references a real, high_value AssetCategory is checked in + * createAssetModel() below, not here — that requires a DB lookup. + */ +export function validateCreateAssetModelInput(input: { + categoryId?: unknown; + manufacturer?: unknown; + name?: unknown; + specs?: unknown; +}): ValidationResult { + const result = createAssetModelSchema.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; + manufacturer: string; + name: string; + specs: Prisma.JsonValue; + createdAt: Date; +}): AssetModelRecord { + return { + id: row.id, + category_id: row.categoryId, + manufacturer: row.manufacturer, + name: row.name, + specs: (row.specs as Record) ?? {}, + created_at: row.createdAt.toISOString(), + }; +} + +export type CreateAssetModelResult = + | { ok: true; value: AssetModelRecord } + | { ok: false; error: string }; + +/** + * Creates an AssetModel. categoryId must reference a real AssetCategory + * (checked here via an explicit prisma.assetCategory.findUnique() lookup — + * not left to Prisma's P2003 FK-violation catch like other entities' + * simple FK checks, e.g. src/lib/asset-category-attribute-defs.ts's + * categoryId handling), because there's an additional business rule to + * enforce beyond "does this row exist": the referenced category's + * valueGroup must be `high_value` (per docs/asset-license-management.md's + * "Ràng buộc dữ liệu" section under "Cập nhật 2026-08-02 (2)") — a rule + * that can't be expressed as a Postgres FK constraint, so it can't be + * caught via a Prisma error code the way a plain missing-FK case can. If + * the category doesn't exist at all, or exists but is `low_value`, this + * returns a distinct {ok:false,error} for each case rather than a raw + * P2003/500. + */ +export async function createAssetModel( + input: CreateAssetModelInput +): Promise { + const category = await prisma.assetCategory.findUnique({ + where: { id: input.categoryId }, + }); + + if (!category) { + return { ok: false, error: "categoryId does not reference an existing asset category" }; + } + + if (category.valueGroup !== "high_value") { + return { ok: false, error: "categoryId must reference a category with valueGroup=high_value" }; + } + + const created = await prisma.assetModel.create({ + data: { + categoryId: input.categoryId, + manufacturer: input.manufacturer, + name: input.name, + // input.specs is validated by Zod to be a plain JSON object + // (Record), but Prisma's generated InputJsonValue + // type doesn't structurally accept an index-signature Record — cast + // is safe here since the Zod schema already guarantees JSON-shaped + // values. + specs: input.specs as Prisma.InputJsonValue, + }, + }); + return { ok: true, value: toRecord(created) }; +} + +export async function listAssetModels(): Promise { + const rows = await prisma.assetModel.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + return rows.map(toRecord); +}