feat: add PurchaseOrder validation + CRUD (src/lib/purchase-orders.ts)
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user