feat: add InventoryScan validation + CRUD (src/lib/inventory-scans.ts)
Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1e6dec9cf2
commit
8a02a7c7ef
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user