diff --git a/src/lib/purchase-orders.ts b/src/lib/purchase-orders.ts new file mode 100644 index 0000000..45c8970 --- /dev/null +++ b/src/lib/purchase-orders.ts @@ -0,0 +1,161 @@ +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { prisma } from "./prisma"; + +export interface PurchaseOrderRecord { + id: string; + vendor_id: string; + order_date: string; + expected_date: string; + received_at: string | null; + received_by: string | null; + created_at: string; +} + +export interface CreatePurchaseOrderInput { + vendorId: string; + orderDate: Date; + expectedDate: Date; + receivedAt?: Date | null; + receivedById?: string | null; +} + +export type ValidationResult = + | { ok: true; value: CreatePurchaseOrderInput } + | { ok: false; error: string }; + +// Mirrors src/lib/licenses.ts's dateString helper — accepts any string +// Date.parse() can handle and transforms it into a Date for Prisma. Used +// here for orderDate/expectedDate/receivedAt, which are all Timestamptz +// (not just Date) columns, so datetime strings (not just "2026-07-31") are +// also accepted. +function dateString(message: string) { + return z + .string({ required_error: message, invalid_type_error: message }) + .refine((value) => !Number.isNaN(Date.parse(value)), { message }) + .transform((value) => new Date(value)); +} + +const createPurchaseOrderSchema = z + .object({ + // FK to Vendor — only a UUID *format* check here, whether it references + // a real vendor is left to Prisma's own FK constraint at insert time + // (see createPurchaseOrder()'s P2003 handling below). Mirrors + // src/lib/licenses.ts's vendorId handling. + vendorId: z + .string({ required_error: "vendorId is required and must be a valid UUID string" }) + .uuid("vendorId is required and must be a valid UUID string"), + orderDate: dateString("orderDate is required and must be a valid date string"), + expectedDate: dateString("expectedDate is required and must be a valid date string"), + // Both nullable — null means "not yet received". Co-nullability with + // receivedById is enforced by the .refine() below, not here. + receivedAt: dateString("receivedAt must be a valid date string").optional().nullable(), + receivedById: z + .string({ invalid_type_error: "receivedById must be a valid UUID string" }) + .uuid("receivedById must be a valid UUID string") + .optional() + .nullable(), + }) + .refine( + (value) => { + const hasReceivedAt = value.receivedAt !== undefined && value.receivedAt !== null; + const hasReceivedById = value.receivedById !== undefined && value.receivedById !== null; + return hasReceivedAt === hasReceivedById; + }, + { + message: "receivedAt and receivedById must both be set or both be unset", + // Attach the error to both fields so it surfaces regardless of which + // one the caller looks at first. + path: ["receivedAt"], + } + ); + +/** + * Validates raw (untyped) purchase-order-creation input. vendorId must be a + * valid UUID string, orderDate/expectedDate must be valid date strings, + * receivedAt/receivedById are optional/nullable but must both be set or + * both be unset (co-nullability invariant — receiving a PO always records + * both "when" and "who" together). No status field is accepted — PurchaseOrder + * deliberately has no stored status (see schema.prisma comment), it's + * inferred from linked Requests. + */ +export function validateCreatePurchaseOrderInput(input: { + vendorId?: unknown; + orderDate?: unknown; + expectedDate?: unknown; + receivedAt?: unknown; + receivedById?: unknown; +}): ValidationResult { + const result = createPurchaseOrderSchema.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 }; +} + +function toRecord(row: { + id: string; + vendorId: string; + orderDate: Date; + expectedDate: Date; + receivedAt: Date | null; + receivedById: string | null; + createdAt: Date; +}): PurchaseOrderRecord { + return { + id: row.id, + vendor_id: row.vendorId, + order_date: row.orderDate.toISOString(), + expected_date: row.expectedDate.toISOString(), + received_at: row.receivedAt ? row.receivedAt.toISOString() : null, + received_by: row.receivedById, + created_at: row.createdAt.toISOString(), + }; +} + +export type CreatePurchaseOrderResult = + | { ok: true; value: PurchaseOrderRecord } + | { ok: false; error: string }; + +/** + * Creates a PurchaseOrder. Two separate FKs can fail here (vendorId -> + * Vendor, receivedById -> Employee) — Prisma raises the same error code + * (P2003) for either, so the FK constraint name in `error.meta.field_name` + * is used to tell them apart and return a targeted message, rather than a + * raw Prisma error leaking to the API layer. Mirrors + * src/lib/licenses.ts's createLicense() pattern, extended for the + * two-FK case. + */ +export async function createPurchaseOrder( + input: CreatePurchaseOrderInput +): Promise { + try { + const created = await prisma.purchaseOrder.create({ + data: { + vendorId: input.vendorId, + orderDate: input.orderDate, + expectedDate: input.expectedDate, + receivedAt: input.receivedAt ?? null, + receivedById: input.receivedById ?? null, + }, + }); + return { ok: true, value: toRecord(created) }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { + const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : ""; + if (fieldName.includes("received_by")) { + return { ok: false, error: "receivedById does not reference an existing employee" }; + } + return { ok: false, error: "vendorId does not reference an existing vendor" }; + } + throw error; + } +} + +export async function listPurchaseOrders(): Promise { + const rows = await prisma.purchaseOrder.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + return rows.map(toRecord); +}