fix: retrofit AssetItem.model to modelId FK

AssetItem.model was a free-text string with no referential integrity.
Now a required FK to AssetModel, mirroring the Request.employeeId and
License.vendorId retrofit precedent (no backfill needed, no real
AssetItem data existed).
This commit is contained in:
toanhoang
2026-08-02 21:07:12 -04:00
parent d280279322
commit 2a5792df67
5 changed files with 169 additions and 46 deletions
+2 -2
View File
@@ -22,11 +22,11 @@ export async function POST(request: Request): Promise<NextResponse> {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { serialNumber, model, purchaseDate, warrantyEndDate, purchaseOrderId } =
const { serialNumber, modelId, purchaseDate, warrantyEndDate, purchaseOrderId } =
body as Record<string, unknown>;
const validation = validateCreateAssetItemInput({
serialNumber,
model,
modelId,
purchaseDate,
warrantyEndDate,
purchaseOrderId,
+35 -19
View File
@@ -7,7 +7,7 @@ export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired";
export interface AssetItemRecord {
id: string;
serial_number: string;
model: string;
model_id: string;
purchase_date: string;
warranty_end_date: string | null;
status: AssetItemStatus;
@@ -17,7 +17,7 @@ export interface AssetItemRecord {
export interface CreateAssetItemInput {
serialNumber: string;
model: string;
modelId: string;
purchaseDate: Date;
warrantyEndDate?: Date;
purchaseOrderId?: string | null;
@@ -47,7 +47,15 @@ function dateString(message: string) {
const createAssetItemSchema = z.object({
serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"),
model: nonEmptyString("model is required and must be a non-empty string"),
// Required FK to AssetModel — replaced the free-text `model` String (see
// schema.prisma / docs/asset-license-management.md's "Cập nhật 2026-08-02
// (2)" section). Only a UUID *format* check here, whether it references a
// real AssetModel is left to Prisma's own FK constraint at insert time
// (see createAssetItem()'s P2003 handling below). Mirrors
// src/lib/licenses.ts's vendorId handling.
modelId: z
.string({ required_error: "modelId is required and must be a valid UUID string" })
.uuid("modelId is required and must be a valid UUID string"),
purchaseDate: dateString("purchaseDate is required and must be a valid date string"),
warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(),
// FK to PurchaseOrder — nullable/optional, supply-side traceability (not
@@ -64,17 +72,17 @@ const createAssetItemSchema = z.object({
});
/**
* Validates raw (untyped) asset-item-creation input. serialNumber/model
* must be non-empty strings, purchaseDate must be a valid date string,
* warrantyEndDate is optional but must be a valid date string if present.
* purchaseOrderId is optional/nullable but must be a valid UUID if present.
* status is not accepted here — new assets always start "in_stock" (same
* pattern as Request always starting "Draft"), later states are set via
* other operations, not at creation.
* Validates raw (untyped) asset-item-creation input. serialNumber must be a
* non-empty string, modelId must be a valid UUID string, purchaseDate must
* be a valid date string, warrantyEndDate is optional but must be a valid
* date string if present. purchaseOrderId is optional/nullable but must be
* a valid UUID if present. status is not accepted here — new assets always
* start "in_stock" (same pattern as Request always starting "Draft"), later
* states are set via other operations, not at creation.
*/
export function validateCreateAssetItemInput(input: {
serialNumber?: unknown;
model?: unknown;
modelId?: unknown;
purchaseDate?: unknown;
warrantyEndDate?: unknown;
purchaseOrderId?: unknown;
@@ -90,7 +98,7 @@ export function validateCreateAssetItemInput(input: {
function toRecord(row: {
id: string;
serialNumber: string;
model: string;
modelId: string;
purchaseDate: Date;
warrantyEndDate: Date | null;
status: AssetItemStatus;
@@ -100,7 +108,7 @@ function toRecord(row: {
return {
id: row.id,
serial_number: row.serialNumber,
model: row.model,
model_id: row.modelId,
purchase_date: row.purchaseDate.toISOString(),
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
status: row.status,
@@ -119,10 +127,14 @@ export type CreateAssetItemResult =
* caught here and translated into the same `{ok:false,error}` shape as
* input validation failures, rather than letting a raw Prisma error leak
* to the API layer. Mirrors createEmployee()/createRequest()'s P2003
* handling for FK violations. If purchaseOrderId doesn't reference an
* existing PurchaseOrder, Prisma raises P2003 (FK constraint violation) —
* caught the same way, mirrors src/lib/purchase-orders.ts's
* createPurchaseOrder() P2003 handling.
* handling for FK violations. Two separate FKs can fail here (modelId ->
* AssetModel, purchaseOrderId -> PurchaseOrder) — Prisma raises the same
* error code (P2003) for either, so the FK constraint name in
* `error.meta.field_name` is used to tell them apart and return a targeted
* message, rather than a raw Prisma error leaking to the API layer. Mirrors
* src/lib/licenses.ts's createLicense() / src/lib/purchase-orders.ts's
* createPurchaseOrder() pattern for disambiguating between two FKs on the
* same model.
*/
export async function createAssetItem(
input: CreateAssetItemInput
@@ -131,7 +143,7 @@ export async function createAssetItem(
const created = await prisma.assetItem.create({
data: {
serialNumber: input.serialNumber,
model: input.model,
modelId: input.modelId,
purchaseDate: input.purchaseDate,
warrantyEndDate: input.warrantyEndDate ?? null,
purchaseOrderId: input.purchaseOrderId ?? null,
@@ -143,7 +155,11 @@ export async function createAssetItem(
return { ok: false, error: "serialNumber already exists" };
}
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" };
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
if (fieldName.includes("purchase_order")) {
return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" };
}
return { ok: false, error: "modelId does not reference an existing asset model" };
}
throw error;
}