import { z } from "zod"; import { Prisma } from "@prisma/client"; import type { AssetAttributeDataType } from "./asset-category-attribute-defs"; import { prisma } from "./prisma"; export interface AssetModelRecord { id: string; category_id: string; manufacturer: string; name: string; specs: Record; warranty_months: number | null; created_at: string; } export interface CreateAssetModelInput { categoryId: string; manufacturer: string; name: string; specs: Record; warrantyMonths?: number | null; } 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 at this stage — just "a plain JSON object" (no // arrays, no primitives at the top level). The category-specific dynamic // shape (which keys are allowed, which are required, and their types) is // validated separately in createAssetModel() below, via a runtime Zod // schema built from that category's AssetCategoryAttributeDef rows — it // can't be done here because that requires a DB lookup keyed on // categoryId, which this pure-input-shape schema doesn't have access to. // 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({}), // Optional standard warranty length -- see schema.prisma's // AssetModel.warrantyMonths comment for what this drives. warrantyMonths: z .number({ invalid_type_error: "warrantyMonths must be a positive integer" }) .int("warrantyMonths must be a positive integer") .positive("warrantyMonths must be a positive integer") .optional() .nullable(), }); /** * 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; warrantyMonths?: 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; warrantyMonths: number | null; createdAt: Date; }): AssetModelRecord { return { id: row.id, category_id: row.categoryId, manufacturer: row.manufacturer, name: row.name, specs: (row.specs as Record) ?? {}, warranty_months: row.warrantyMonths, created_at: row.createdAt.toISOString(), }; } // Maps an AssetCategoryAttributeDef.dataType to the corresponding Zod leaf // type, per docs/asset-license-management.md's "Cập nhật 2026-08-02 (3)" // section: string -> z.string(), number -> z.number(), boolean -> // z.boolean(), date -> z.coerce.date() (coerce so a JSON-transported date // *string*, e.g. "2027-01-01", is accepted — JSON has no native Date type). function zodTypeForDataType(dataType: AssetAttributeDataType): z.ZodTypeAny { switch (dataType) { case "string": return z.string(); case "number": return z.number(); case "boolean": return z.boolean(); case "date": return z.coerce.date(); } } /** * Builds a Zod object schema AT RUNTIME from a category's * AssetCategoryAttributeDef rows: one shape entry per attribute def (typed * per zodTypeForDataType() above), optional when `required` is false via * `.optional()`, and `.strict()` on the overall object so any `specs` key * not defined for this category is rejected (no unknown keys allowed), per * the doc section referenced above. */ function buildSpecsSchema( attributeDefs: { key: string; dataType: AssetAttributeDataType; required: boolean }[] ): z.ZodTypeAny { const shape: Record = {}; for (const def of attributeDefs) { const leaf = zodTypeForDataType(def.dataType); shape[def.key] = def.required ? leaf : leaf.optional(); } return z.object(shape).strict(); } 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" }; } // Dynamic specs validation, per docs/asset-license-management.md's // "Cập nhật 2026-08-02 (3)" section: build a Zod schema at runtime from // this category's AssetCategoryAttributeDef rows, and validate the // incoming specs object against it. Deliberately done here (after the // category existence/high_value checks above), not inside // validateCreateAssetModelInput()'s static schema, because it needs a DB // lookup keyed on categoryId. const attributeDefs = await prisma.assetCategoryAttributeDef.findMany({ where: { categoryId: input.categoryId }, }); const specsSchema = buildSpecsSchema(attributeDefs); const specsResult = specsSchema.safeParse(input.specs); if (!specsResult.success) { const message = specsResult.error.issues[0]?.message ?? "Invalid specs"; const path = specsResult.error.issues[0]?.path.join("."); return { ok: false, error: path ? `specs.${path}: ${message}` : `specs: ${message}`, }; } 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, warrantyMonths: input.warrantyMonths ?? null, }, }); 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); }