From 8a02a7c7ef6a860a296b4aa8559838f8dfdde0a4 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Wed, 19 Aug 2026 22:07:00 -0400 Subject: [PATCH] feat: add InventoryScan validation + CRUD (src/lib/inventory-scans.ts) Co-Authored-By: Claude Sonnet 5 --- src/lib/inventory-scans.ts | 251 +++++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 src/lib/inventory-scans.ts diff --git a/src/lib/inventory-scans.ts b/src/lib/inventory-scans.ts new file mode 100644 index 0000000..3d391ec --- /dev/null +++ b/src/lib/inventory-scans.ts @@ -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; + 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; + 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; +} + +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) ?? {}, + 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 { + 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 { + 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 { + 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 { + 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 { + const row = await prisma.inventoryScan.findUnique({ where: { id }, include: includeForRecord }); + return row ? toRecord(row) : null; +}