feat: add AssetItem validation + CRUD (src/lib/assets.ts)

This commit is contained in:
2026-07-31 16:04:22 +07:00
parent 09b54f3c6b
commit e7e359cbf7
+133
View File
@@ -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<CreateAssetItemResult> {
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<AssetItemRecord[]> {
const rows = await prisma.assetItem.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}