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