From e7e359cbf7b2b2104f71c930377005f07b1d3abd Mon Sep 17 00:00:00 2001 From: Toan Hoang Minh Date: Fri, 31 Jul 2026 16:04:22 +0700 Subject: [PATCH] feat: add AssetItem validation + CRUD (src/lib/assets.ts) --- src/lib/assets.ts | 133 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/lib/assets.ts diff --git a/src/lib/assets.ts b/src/lib/assets.ts new file mode 100644 index 0000000..684bb38 --- /dev/null +++ b/src/lib/assets.ts @@ -0,0 +1,133 @@ +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { prisma } from "./prisma"; + +export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired"; + +export interface AssetItemRecord { + id: string; + serial_number: string; + model: string; + purchase_date: string; + warranty_end_date: string | null; + status: AssetItemStatus; + created_at: string; +} + +export interface CreateAssetItemInput { + serialNumber: string; + model: string; + purchaseDate: Date; + warrantyEndDate?: Date; +} + +export type ValidationResult = + | { ok: true; value: CreateAssetItemInput } + | { ok: false; error: string }; + +// Mirrors src/lib/employees.ts's nonEmptyString helper. +function nonEmptyString(message: string) { + return z.string({ required_error: message, invalid_type_error: message }).refine( + (value) => value.trim() !== "", + { message } + ); +} + +// Accepts any string Date.parse() can handle (e.g. "2026-07-31") and +// transforms it into a Date for Prisma — validation and normalization in +// one step, same spirit as the uuid() checks elsewhere in this codebase. +function dateString(message: string) { + return z + .string({ required_error: message, invalid_type_error: message }) + .refine((value) => !Number.isNaN(Date.parse(value)), { message }) + .transform((value) => new Date(value)); +} + +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"), + purchaseDate: dateString("purchaseDate is required and must be a valid date string"), + warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(), +}); + +/** + * 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. + * 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; + purchaseDate?: unknown; + warrantyEndDate?: unknown; +}): ValidationResult { + const result = createAssetItemSchema.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; + serialNumber: string; + model: string; + purchaseDate: Date; + warrantyEndDate: Date | null; + status: AssetItemStatus; + createdAt: Date; +}): AssetItemRecord { + return { + id: row.id, + serial_number: row.serialNumber, + model: row.model, + purchase_date: row.purchaseDate.toISOString(), + warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null, + status: row.status, + created_at: row.createdAt.toISOString(), + }; +} + +export type CreateAssetItemResult = + | { ok: true; value: AssetItemRecord } + | { ok: false; error: string }; + +/** + * Creates an AssetItem. If serialNumber collides with an existing row, + * Prisma raises a known error (code P2002, unique constraint violation) — + * 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. + */ +export async function createAssetItem( + input: CreateAssetItemInput +): Promise { + try { + const created = await prisma.assetItem.create({ + data: { + serialNumber: input.serialNumber, + model: input.model, + purchaseDate: input.purchaseDate, + warrantyEndDate: input.warrantyEndDate ?? null, + }, + }); + return { ok: true, value: toRecord(created) }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + return { ok: false, error: "serialNumber already exists" }; + } + throw error; + } +} + +export async function listAssetItems(): Promise { + const rows = await prisma.assetItem.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + return rows.map(toRecord); +}