Files
ubi-dan-itam/src/lib/asset-models.ts
T
toanhoang d9b360c1df
CI / test (push) Failing after 8s
Auto-fill AssetItem.warrantyEndDate from AssetModel.warrantyMonths
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.
2026-08-21 09:10:55 -04:00

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);
}