CI / test (push) Failing after 8s
AssetModel gains an optional warrantyMonths (standard vendor warranty length). createAssetItem() now defaults warrantyEndDate to purchaseDate + warrantyMonths (month-end clamped, e.g. Dec 31 + 2mo -> Feb 28, not Mar 3) whenever the caller omits warrantyEndDate -- still overridable per item for batches with extended/different warranty terms. /asset-models UI gets an input for the new field; /assets needs no change since it already omits warrantyEndDate from the payload when left blank. Hand-written migration + manual smoke test (tsx script, not Vitest), same deviation as the other 2026-08-2x work in this branch -- see CLAUDE.md. tsc/lint clean.
229 lines
8.7 KiB
TypeScript
229 lines
8.7 KiB
TypeScript
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<string, unknown>;
|
|
warranty_months: number | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface CreateAssetModelInput {
|
|
categoryId: string;
|
|
manufacturer: string;
|
|
name: string;
|
|
specs: Record<string, unknown>;
|
|
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<string, unknown>) ?? {},
|
|
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<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 };
|
|
|
|
/**
|
|
* 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<CreateAssetModelResult> {
|
|
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<string, unknown>), 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<AssetModelRecord[]> {
|
|
const rows = await prisma.assetModel.findMany({
|
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
|
});
|
|
return rows.map(toRecord);
|
|
}
|