feat: add PurchaseOrder validation + CRUD (src/lib/purchase-orders.ts)

This commit is contained in:
toanhoang
2026-08-02 19:59:05 -04:00
parent 58235112f0
commit cc98ca4d51
+161
View File
@@ -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<CreatePurchaseOrderResult> {
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<PurchaseOrderRecord[]> {
const rows = await prisma.purchaseOrder.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}