feat: add AssetItem validation + CRUD (src/lib/assets.ts)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user