// 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; }