feat: wire dynamic specs validation into AssetModel creation

This commit is contained in:
toanhoang
2026-08-02 20:58:19 -04:00
parent bcd41e4ba3
commit dc4c438ca9
2 changed files with 299 additions and 7 deletions
+67 -6
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
import type { AssetAttributeDataType } from "./asset-category-attribute-defs";
import { prisma } from "./prisma";
export interface AssetModelRecord {
@@ -42,12 +43,14 @@ const createAssetModelSchema = z.object({
.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.
// 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",
@@ -95,6 +98,43 @@ function toRecord(row: {
};
}
// 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<string, z.ZodTypeAny> = {};
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 };
@@ -129,6 +169,27 @@ export async function createAssetModel(
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,