155 lines
5.3 KiB
TypeScript
155 lines
5.3 KiB
TypeScript
import { z } from "zod";
|
|
import { Prisma } from "@prisma/client";
|
|
import { prisma } from "./prisma";
|
|
|
|
export type RequestType = "asset" | "license";
|
|
|
|
// Only the 'Draft' state is implemented in this task. Later stages
|
|
// (Pending Approval, Approved, Ordered, Received, Delivered, Closed,
|
|
// Rejected/Cancelled — see processes/asset-license-management.md) are
|
|
// deliberately out of scope.
|
|
export type RequestStatus = "Draft";
|
|
|
|
export interface RequestRecord {
|
|
id: string;
|
|
employee_id: string;
|
|
type: RequestType;
|
|
reason: string;
|
|
status: RequestStatus;
|
|
purchase_order_id: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface CreateRequestInput {
|
|
employee_id: string;
|
|
type: RequestType;
|
|
reason: string;
|
|
purchase_order_id?: string | null;
|
|
}
|
|
|
|
export type ValidationResult =
|
|
| { ok: true; value: CreateRequestInput }
|
|
| { ok: false; error: string };
|
|
|
|
// A string field that must be present and not just whitespace. Mirrors the
|
|
// old hand-written `typeof x !== "string" || x.trim() === ""` check: the
|
|
// value itself is NOT trimmed/transformed, only validated — the raw input is
|
|
// still what gets stored.
|
|
function nonEmptyString(message: string) {
|
|
return z.string({ required_error: message, invalid_type_error: message }).refine(
|
|
(value) => value.trim() !== "",
|
|
{ message }
|
|
);
|
|
}
|
|
|
|
const createRequestSchema = z.object({
|
|
// Now a real FK to Employee (see schema.prisma) — only a UUID *format*
|
|
// check here — whether it references a real employee is left to Prisma's
|
|
// own FK constraint at insert time (see createRequest()'s P2003 handling
|
|
// below), rather than an extra pre-check query. Mirrors the same pattern
|
|
// used for Employee.managerId in src/lib/employees.ts.
|
|
employee_id: z
|
|
.string({ required_error: "employee_id is required and must be a valid UUID string" })
|
|
.uuid("employee_id is required and must be a valid UUID string"),
|
|
reason: nonEmptyString("reason is required and must be a non-empty string"),
|
|
type: z.enum(["asset", "license"], {
|
|
errorMap: () => ({
|
|
message: "type is required and must be exactly 'asset' or 'license'",
|
|
}),
|
|
}),
|
|
// Nullable/optional FK to PurchaseOrder — see schema.prisma's comment on
|
|
// Request.purchaseOrderId for the demand-vs-supply-side distinction from
|
|
// AssetItem/License's purchaseOrderId. Only a UUID *format* check here,
|
|
// whether it references a real PurchaseOrder is left to Prisma's own FK
|
|
// constraint at insert time (see createRequest()'s P2003 handling below).
|
|
// Mirrors src/lib/assets.ts's purchaseOrderId optional().nullable()
|
|
// pattern exactly.
|
|
purchase_order_id: z
|
|
.string({ invalid_type_error: "purchase_order_id must be a valid UUID string" })
|
|
.uuid("purchase_order_id must be a valid UUID string")
|
|
.optional()
|
|
.nullable(),
|
|
});
|
|
|
|
/**
|
|
* Validates raw (untyped) request-creation input.
|
|
* employee_id and reason must be non-empty strings; type must be exactly
|
|
* 'asset' or 'license'.
|
|
*/
|
|
export function validateCreateRequestInput(input: {
|
|
employee_id?: unknown;
|
|
type?: unknown;
|
|
reason?: unknown;
|
|
purchase_order_id?: unknown;
|
|
}): ValidationResult {
|
|
const result = createRequestSchema.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;
|
|
employeeId: string;
|
|
type: RequestType;
|
|
reason: string;
|
|
status: string;
|
|
purchaseOrderId: string | null;
|
|
createdAt: Date;
|
|
}): RequestRecord {
|
|
return {
|
|
id: row.id,
|
|
employee_id: row.employeeId,
|
|
type: row.type,
|
|
reason: row.reason,
|
|
status: row.status as RequestStatus,
|
|
purchase_order_id: row.purchaseOrderId,
|
|
created_at: row.createdAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
export type CreateRequestResult =
|
|
| { ok: true; value: RequestRecord }
|
|
| { ok: false; error: string };
|
|
|
|
/**
|
|
* Creates a Request. Two separate FKs can fail here (employee_id ->
|
|
* Employee, purchase_order_id -> PurchaseOrder) — 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 for disambiguating between
|
|
* two FKs on the same model.
|
|
*/
|
|
export async function createRequest(input: CreateRequestInput): Promise<CreateRequestResult> {
|
|
try {
|
|
const created = await prisma.request.create({
|
|
data: {
|
|
employeeId: input.employee_id,
|
|
type: input.type,
|
|
reason: input.reason,
|
|
purchaseOrderId: input.purchase_order_id ?? 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("purchase_order")) {
|
|
return { ok: false, error: "purchase_order_id does not reference an existing purchase order" };
|
|
}
|
|
return { ok: false, error: "employee_id does not reference an existing employee" };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function listRequests(): Promise<RequestRecord[]> {
|
|
const rows = await prisma.request.findMany({
|
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
|
});
|
|
return rows.map(toRecord);
|
|
}
|