From 2a5792df671e0858804174f9272bab7d413f9557 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 21:07:12 -0400 Subject: [PATCH] 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). --- .../migration.sql | 13 ++ prisma/schema.prisma | 15 +- src/app/api/assets/route.ts | 4 +- src/lib/assets.ts | 54 +++++--- tests/assets.test.ts | 129 ++++++++++++++---- 5 files changed, 169 insertions(+), 46 deletions(-) create mode 100644 prisma/migrations/20260803010401_retrofit_asset_item_model_id/migration.sql diff --git a/prisma/migrations/20260803010401_retrofit_asset_item_model_id/migration.sql b/prisma/migrations/20260803010401_retrofit_asset_item_model_id/migration.sql new file mode 100644 index 0000000..f6f5b75 --- /dev/null +++ b/prisma/migrations/20260803010401_retrofit_asset_item_model_id/migration.sql @@ -0,0 +1,13 @@ +/* + Warnings: + + - You are about to drop the column `model` on the `asset_items` table. All the data in the column will be lost. + - Added the required column `model_id` to the `asset_items` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "asset_items" DROP COLUMN "model", +ADD COLUMN "model_id" UUID NOT NULL; + +-- AddForeignKey +ALTER TABLE "asset_items" ADD CONSTRAINT "asset_items_model_id_fkey" FOREIGN KEY ("model_id") REFERENCES "asset_models"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4e13dfd..22f0dce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -97,10 +97,20 @@ enum AssetItemStatus { // a formal PO (e.g. historical stock predating the system). Set at // intake/creation time only — no update endpoint for this field yet, same // precedent as License.vendorId being set-at-creation with no PATCH. +// `modelId` was a free-text `model` String until the "Cập nhật 2026-08-02 +// (2)" retrofit (docs/asset-license-management.md's "Ràng buộc dữ liệu" +// bullet) — same precedent as Request.employeeId/License.vendorId (string +// -> FK once the referenced entity existed). No production `AssetItem` data +// existed at the time of this change, so this was a plain column-type +// migration, not a backfill. Required (not nullable) — every AssetItem must +// belong to a real AssetModel. AssetItem deliberately does NOT carry its own +// `categoryId` — category is derived via `modelId -> AssetModel.categoryId` +// to avoid two sources of truth that could drift apart. model AssetItem { id String @id @default(uuid()) @db.Uuid serialNumber String @unique @map("serial_number") - model String + modelId String @map("model_id") @db.Uuid + model AssetModel @relation(fields: [modelId], references: [id]) purchaseDate DateTime @map("purchase_date") @db.Date warrantyEndDate DateTime? @map("warranty_end_date") @db.Date status AssetItemStatus @default(in_stock) @@ -327,6 +337,9 @@ model AssetModel { name String specs Json @default("{}") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + // Back-relation for the AssetItem.model -> AssetItem.modelId retrofit — + // see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md. + assetItems AssetItem[] @@map("asset_models") } diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts index 1aada88..65b8744 100644 --- a/src/app/api/assets/route.ts +++ b/src/app/api/assets/route.ts @@ -22,11 +22,11 @@ export async function POST(request: Request): Promise { 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; const validation = validateCreateAssetItemInput({ serialNumber, - model, + modelId, purchaseDate, warrantyEndDate, purchaseOrderId, diff --git a/src/lib/assets.ts b/src/lib/assets.ts index dcfd700..30b8530 100644 --- a/src/lib/assets.ts +++ b/src/lib/assets.ts @@ -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; } diff --git a/tests/assets.test.ts b/tests/assets.test.ts index 51f8f9e..480e3d4 100644 --- a/tests/assets.test.ts +++ b/tests/assets.test.ts @@ -50,16 +50,52 @@ function createTestPurchaseOrder(overrides: { vendorId?: string } = {}) { }); } +// AssetItem.modelId is a real FK to AssetModel (see schema.prisma / +// src/lib/assets.ts), and AssetModel.categoryId is a real FK to +// AssetCategory — tests that need a valid modelId create the full chain +// first and use the model's id. Mirrors tests/asset-models.test.ts's +// createTestAssetCategory() helper. Category is always high_value here — +// AssetModel only ever references high_value categories per +// src/lib/asset-models.ts's business rule. +function createTestAssetCategory(overrides: { name?: string } = {}) { + return prisma.assetCategory.create({ + data: { + name: overrides.name ?? "Laptop", + valueGroup: "high_value", + }, + }); +} + +function createTestAssetModel(overrides: { categoryId?: string } = {}) { + return prisma.assetModel.create({ + data: { + categoryId: overrides.categoryId as string, + manufacturer: "Dell", + name: "Latitude 5440", + }, + }); +} + +async function createTestModelId(): Promise { + const category = await createTestAssetCategory(); + const model = await createTestAssetModel({ categoryId: category.id }); + return model.id; +} + beforeEach(async () => { // Each test starts from empty tables so listing/order assertions are // deterministic. asset_items must be cleared before purchase_orders/ - // vendors since asset_items has FKs (ON DELETE SET NULL to - // purchase_orders) — deleteMany order avoids relying on that cascade - // behavior in tests. purchase_orders must be cleared before vendors - // (ON DELETE RESTRICT to vendors, mirrors tests/purchase-orders.test.ts). + // vendors/asset_models/asset_categories since asset_items has FKs to all + // of them — deleteMany order avoids relying on cascade behavior in tests. + // purchase_orders must be cleared before vendors (ON DELETE RESTRICT to + // vendors, mirrors tests/purchase-orders.test.ts). asset_models must be + // cleared before asset_categories (ON DELETE RESTRICT, mirrors + // tests/asset-models.test.ts's cleanup order). await prisma.assetItem.deleteMany(); await prisma.purchaseOrder.deleteMany(); await prisma.vendor.deleteMany(); + await prisma.assetModel.deleteMany(); + await prisma.assetCategory.deleteMany(); // Default to a logged-in session — the tests below exercise business // logic, not auth gating (that's covered by the "auth gating" describe // block further down). @@ -68,8 +104,9 @@ beforeEach(async () => { describe("POST /api/assets", () => { it("creates an asset item in in_stock status on valid input, with no warrantyEndDate", async () => { + const modelId = await createTestModelId(); const res = await POST( - postRequest({ serialNumber: "SN-001", model: "Dell Latitude 5440", purchaseDate: "2026-01-15" }) + postRequest({ serialNumber: "SN-001", modelId, purchaseDate: "2026-01-15" }) ); expect(res.status).toBe(201); @@ -77,7 +114,7 @@ describe("POST /api/assets", () => { expect(body).toMatchObject({ serial_number: "SN-001", - model: "Dell Latitude 5440", + model_id: modelId, status: "in_stock", warranty_end_date: null, }); @@ -88,10 +125,11 @@ describe("POST /api/assets", () => { }); it("creates an asset item with a warrantyEndDate", async () => { + const modelId = await createTestModelId(); const res = await POST( postRequest({ serialNumber: "SN-002", - model: "Dell Latitude 5440", + modelId, purchaseDate: "2026-01-15", warrantyEndDate: "2029-01-15", }) @@ -103,24 +141,26 @@ describe("POST /api/assets", () => { }); it("rejects an empty serialNumber", async () => { - const res = await POST(postRequest({ serialNumber: "", model: "Dell", purchaseDate: "2026-01-15" })); + const modelId = await createTestModelId(); + const res = await POST(postRequest({ serialNumber: "", modelId, purchaseDate: "2026-01-15" })); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/serialNumber/i); }); - it("rejects an empty model", async () => { - const res = await POST(postRequest({ serialNumber: "SN-003", model: "", purchaseDate: "2026-01-15" })); + it("rejects a missing modelId", async () => { + const res = await POST(postRequest({ serialNumber: "SN-003", purchaseDate: "2026-01-15" })); expect(res.status).toBe(400); const body = await res.json(); - expect(body.error).toMatch(/model/i); + expect(body.error).toMatch(/modelId/i); }); it("rejects an invalid purchaseDate", async () => { + const modelId = await createTestModelId(); const res = await POST( - postRequest({ serialNumber: "SN-004", model: "Dell", purchaseDate: "not-a-date" }) + postRequest({ serialNumber: "SN-004", modelId, purchaseDate: "not-a-date" }) ); expect(res.status).toBe(400); @@ -129,10 +169,11 @@ describe("POST /api/assets", () => { }); it("rejects an invalid warrantyEndDate", async () => { + const modelId = await createTestModelId(); const res = await POST( postRequest({ serialNumber: "SN-005", - model: "Dell", + modelId, purchaseDate: "2026-01-15", warrantyEndDate: "not-a-date", }) @@ -144,13 +185,14 @@ describe("POST /api/assets", () => { }); it("creates an asset item with a purchaseOrderId that references a real purchase order", async () => { + const modelId = await createTestModelId(); const vendor = await createTestVendor(); const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id }); const res = await POST( postRequest({ serialNumber: "SN-007", - model: "Dell Latitude 5440", + modelId, purchaseDate: "2026-01-15", purchaseOrderId: purchaseOrder.id, }) @@ -162,8 +204,9 @@ describe("POST /api/assets", () => { }); it("creates an asset item with no purchaseOrderId (stays null)", async () => { + const modelId = await createTestModelId(); const res = await POST( - postRequest({ serialNumber: "SN-008", model: "Dell Latitude 5440", purchaseDate: "2026-01-15" }) + postRequest({ serialNumber: "SN-008", modelId, purchaseDate: "2026-01-15" }) ); expect(res.status).toBe(201); @@ -172,10 +215,11 @@ describe("POST /api/assets", () => { }); it("rejects a purchaseOrderId that is a well-formed UUID but doesn't reference an existing purchase order", async () => { + const modelId = await createTestModelId(); const res = await POST( postRequest({ serialNumber: "SN-009", - model: "Dell Latitude 5440", + modelId, purchaseDate: "2026-01-15", purchaseOrderId: "00000000-0000-0000-0000-000000000000", }) @@ -192,10 +236,11 @@ describe("POST /api/assets", () => { }); it("rejects a purchaseOrderId that is not a valid UUID", async () => { + const modelId = await createTestModelId(); const res = await POST( postRequest({ serialNumber: "SN-012", - model: "Dell Latitude 5440", + modelId, purchaseDate: "2026-01-15", purchaseOrderId: "not-a-uuid", }) @@ -207,8 +252,9 @@ describe("POST /api/assets", () => { }); it("rejects a duplicate serialNumber", async () => { - await POST(postRequest({ serialNumber: "SN-006", model: "Dell", purchaseDate: "2026-01-15" })); - const res = await POST(postRequest({ serialNumber: "SN-006", model: "HP", purchaseDate: "2026-02-01" })); + const modelId = await createTestModelId(); + await POST(postRequest({ serialNumber: "SN-006", modelId, purchaseDate: "2026-01-15" })); + const res = await POST(postRequest({ serialNumber: "SN-006", modelId, purchaseDate: "2026-02-01" })); expect(res.status).toBe(400); const body = await res.json(); @@ -218,14 +264,48 @@ describe("POST /api/assets", () => { const remaining = await prisma.assetItem.findMany(); expect(remaining).toHaveLength(1); }); + + it("rejects a modelId that is not a valid UUID", async () => { + const res = await POST( + postRequest({ serialNumber: "SN-013", modelId: "not-a-uuid", purchaseDate: "2026-01-15" }) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/modelId/i); + + // And it must not have created anything. + const remaining = await prisma.assetItem.findMany(); + expect(remaining).toHaveLength(0); + }); + + it("rejects a modelId that is a well-formed UUID but doesn't reference an existing asset model", async () => { + const res = await POST( + postRequest({ + serialNumber: "SN-014", + modelId: "00000000-0000-0000-0000-000000000000", + purchaseDate: "2026-01-15", + }) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/modelId/i); + expect(body.error).toMatch(/does not reference an existing asset model/i); + + // And it must not have created anything. + const remaining = await prisma.assetItem.findMany(); + expect(remaining).toHaveLength(0); + }); }); describe("GET /api/assets", () => { it("lists created asset items, newest first", async () => { - await POST(postRequest({ serialNumber: "SN-010", model: "Dell", purchaseDate: "2026-01-15" })); + const modelId = await createTestModelId(); + await POST(postRequest({ serialNumber: "SN-010", modelId, purchaseDate: "2026-01-15" })); // Ensure a distinct created_at ordering even on fast filesystems/clocks. await new Promise((resolve) => setTimeout(resolve, 5)); - await POST(postRequest({ serialNumber: "SN-011", model: "HP", purchaseDate: "2026-02-01" })); + await POST(postRequest({ serialNumber: "SN-011", modelId, purchaseDate: "2026-02-01" })); const res = await GET(); expect(res.status).toBe(200); @@ -235,8 +315,8 @@ describe("GET /api/assets", () => { expect(body).toHaveLength(2); // newest first - expect(body[0]).toMatchObject({ serial_number: "SN-011", model: "HP" }); - expect(body[1]).toMatchObject({ serial_number: "SN-010", model: "Dell" }); + expect(body[0]).toMatchObject({ serial_number: "SN-011", model_id: modelId }); + expect(body[1]).toMatchObject({ serial_number: "SN-010", model_id: modelId }); }); it("returns an empty list when there are no asset items", async () => { @@ -252,10 +332,11 @@ describe("auth gating on /api/assets", () => { // session presence, per the flat permission model (no role/permission // logic). it("POST /api/assets returns 401 without a session", async () => { + const modelId = await createTestModelId(); mockedAuth.mockResolvedValue(null); const res = await POST( - postRequest({ serialNumber: "SN-020", model: "Dell", purchaseDate: "2026-01-15" }) + postRequest({ serialNumber: "SN-020", modelId, purchaseDate: "2026-01-15" }) ); expect(res.status).toBe(401);