feat: add InventoryScan validation + CRUD (src/lib/inventory-scans.ts)

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
toanhoang
2026-08-19 22:07:00 -04:00
co-authored by Claude Sonnet 5
parent 1e6dec9cf2
commit 8a02a7c7ef
+251
View File
@@ -0,0 +1,251 @@
// InventoryScan — one physical item logged during the office-move
// inventory walk (see prisma/schema.prisma's InventoryScan model comment).
// Two write paths: createInventoryScan() (bot ingest, POST
// /api/bot/inventory-scans) and updateInventoryScan() (review-page edit,
// PATCH /api/inventory-scans/[id]) — same list/toRecord read path for both
// the bot (none needed) and the review UI.
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
export interface InventoryScanRecord {
id: string;
category_id: string;
category_name: string;
model_id: string;
model_name: string;
model_manufacturer: string;
area_id: string | null;
area_name: string | null;
assigned_employee_id: string | null;
assigned_employee_name: string | null;
asset_tag: string | null;
serial_number: string | null;
specs: Record<string, unknown>;
photo_file_id: string | null;
created_at: string;
}
export interface CreateInventoryScanInput {
categoryId: string;
modelId: string;
areaId?: string;
assignedEmployeeId?: string;
assetTag?: string;
serialNumber?: string;
specs?: Record<string, unknown>;
photoFileId?: string;
telegramChatId?: string;
telegramMessageId?: number;
}
export type ValidationResult =
| { ok: true; value: CreateInventoryScanInput }
| { ok: false; error: string };
const uuidField = (message: string) =>
z.string({ invalid_type_error: message }).uuid(message);
const createInventoryScanSchema = z.object({
categoryId: uuidField("categoryId must be a valid UUID string"),
modelId: uuidField("modelId must be a valid UUID string"),
areaId: uuidField("areaId must be a valid UUID string").optional(),
assignedEmployeeId: uuidField("assignedEmployeeId must be a valid UUID string").optional(),
assetTag: z.string({ invalid_type_error: "assetTag must be a string" }).optional(),
serialNumber: z.string({ invalid_type_error: "serialNumber must be a string" }).optional(),
specs: z.record(z.unknown(), { invalid_type_error: "specs must be a plain JSON object" }).optional(),
photoFileId: z.string({ invalid_type_error: "photoFileId must be a string" }).optional(),
telegramChatId: z.string({ invalid_type_error: "telegramChatId must be a string" }).optional(),
telegramMessageId: z
.number({ invalid_type_error: "telegramMessageId must be a number" })
.int()
.optional(),
});
export function validateCreateInventoryScanInput(input: {
categoryId?: unknown;
modelId?: unknown;
areaId?: unknown;
assignedEmployeeId?: unknown;
assetTag?: unknown;
serialNumber?: unknown;
specs?: unknown;
photoFileId?: unknown;
telegramChatId?: unknown;
telegramMessageId?: unknown;
}): ValidationResult {
const result = createInventoryScanSchema.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 };
}
export interface UpdateInventoryScanInput {
modelId?: string;
areaId?: string | null;
assignedEmployeeId?: string | null;
assetTag?: string | null;
serialNumber?: string | null;
specs?: Record<string, unknown>;
}
const updateInventoryScanSchema = z.object({
modelId: uuidField("modelId must be a valid UUID string").optional(),
areaId: uuidField("areaId must be a valid UUID string").nullable().optional(),
assignedEmployeeId: uuidField("assignedEmployeeId must be a valid UUID string").nullable().optional(),
assetTag: z.string({ invalid_type_error: "assetTag must be a string" }).nullable().optional(),
serialNumber: z.string({ invalid_type_error: "serialNumber must be a string" }).nullable().optional(),
specs: z.record(z.unknown(), { invalid_type_error: "specs must be a plain JSON object" }).optional(),
});
export type UpdateValidationResult =
| { ok: true; value: UpdateInventoryScanInput }
| { ok: false; error: string };
export function validateUpdateInventoryScanInput(input: {
modelId?: unknown;
areaId?: unknown;
assignedEmployeeId?: unknown;
assetTag?: unknown;
serialNumber?: unknown;
specs?: unknown;
}): UpdateValidationResult {
const result = updateInventoryScanSchema.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 };
}
const includeForRecord = {
category: true,
model: true,
area: true,
assignedEmployee: true,
} satisfies Prisma.InventoryScanInclude;
type ScanWithRelations = Prisma.InventoryScanGetPayload<{ include: typeof includeForRecord }>;
function toRecord(row: ScanWithRelations): InventoryScanRecord {
return {
id: row.id,
category_id: row.categoryId,
category_name: row.category.name,
model_id: row.modelId,
model_name: row.model.name,
model_manufacturer: row.model.manufacturer,
area_id: row.areaId,
area_name: row.area?.name ?? null,
assigned_employee_id: row.assignedEmployeeId,
assigned_employee_name: row.assignedEmployee?.name ?? null,
asset_tag: row.assetTag,
serial_number: row.serialNumber,
specs: (row.specs as Record<string, unknown>) ?? {},
photo_file_id: row.photoFileId,
created_at: row.createdAt.toISOString(),
};
}
export type CreateInventoryScanResult =
| { ok: true; value: InventoryScanRecord }
| { ok: false; error: string };
/**
* Creates an InventoryScan. categoryId/modelId/areaId/assignedEmployeeId
* are all plain FKs with no extra business rule beyond "does the row
* exist" (unlike AssetModel's high_value check) — a missing reference
* raises Prisma's P2003, translated into a clean {ok:false,error} the same
* way src/lib/employees.ts's createEmployee() handles managerId.
*/
export async function createInventoryScan(
input: CreateInventoryScanInput
): Promise<CreateInventoryScanResult> {
try {
const created = await prisma.inventoryScan.create({
data: {
categoryId: input.categoryId,
modelId: input.modelId,
areaId: input.areaId ?? null,
assignedEmployeeId: input.assignedEmployeeId ?? null,
assetTag: input.assetTag ?? null,
serialNumber: input.serialNumber ?? null,
specs: (input.specs ?? {}) as Prisma.InputJsonValue,
photoFileId: input.photoFileId ?? null,
telegramChatId: input.telegramChatId ?? null,
telegramMessageId: input.telegramMessageId ?? null,
},
include: includeForRecord,
});
return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
return { ok: false, error: "categoryId/modelId/areaId/assignedEmployeeId must reference existing rows" };
}
throw error;
}
}
export async function listInventoryScans(): Promise<InventoryScanRecord[]> {
const rows = await prisma.inventoryScan.findMany({
include: includeForRecord,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
export type UpdateInventoryScanResult =
| { ok: true; value: InventoryScanRecord }
| { ok: false; error: string };
export async function updateInventoryScan(
id: string,
input: UpdateInventoryScanInput
): Promise<UpdateInventoryScanResult> {
try {
const updated = await prisma.inventoryScan.update({
where: { id },
data: {
modelId: input.modelId,
areaId: input.areaId,
assignedEmployeeId: input.assignedEmployeeId,
assetTag: input.assetTag,
serialNumber: input.serialNumber,
specs: input.specs as Prisma.InputJsonValue | undefined,
},
include: includeForRecord,
});
return { ok: true, value: toRecord(updated) };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2025") {
return { ok: false, error: "InventoryScan not found" };
}
if (error.code === "P2003") {
return { ok: false, error: "modelId/areaId/assignedEmployeeId must reference existing rows" };
}
}
throw error;
}
}
export type DeleteInventoryScanResult = { ok: true } | { ok: false; error: string };
export async function deleteInventoryScan(id: string): Promise<DeleteInventoryScanResult> {
try {
await prisma.inventoryScan.delete({ where: { id } });
return { ok: true };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
return { ok: false, error: "InventoryScan not found" };
}
throw error;
}
}
export async function getInventoryScan(id: string): Promise<InventoryScanRecord | null> {
const row = await prisma.inventoryScan.findUnique({ where: { id }, include: includeForRecord });
return row ? toRecord(row) : null;
}