import { z } from "zod"; import { Prisma } from "@prisma/client"; import { prisma } from "./prisma"; export type LicenseType = "perpetual" | "subscription"; export interface LicenseRecord { id: string; name: string; vendor_id: string; seats_total: number; seats_used: number; expiry_date: string | null; license_type: LicenseType; purchase_order_id: string | null; created_at: string; } export interface CreateLicenseInput { name: string; vendorId: string; seatsTotal: number; expiryDate?: Date | null; licenseType: LicenseType; purchaseOrderId?: string | null; } export type ValidationResult = | { ok: true; value: CreateLicenseInput } | { ok: false; error: string }; // Mirrors src/lib/assets.ts's nonEmptyString helper. function nonEmptyString(message: string) { return z.string({ required_error: message, invalid_type_error: message }).refine( (value) => value.trim() !== "", { message } ); } // Mirrors src/lib/assets.ts's dateString helper — accepts any string // Date.parse() can handle (e.g. "2026-07-31") and transforms it into a Date // for Prisma. 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 createLicenseSchema = z.object({ name: nonEmptyString("name is required and must be a non-empty string"), // Now a real FK to Vendor (see schema.prisma) — only a UUID *format* // check here — whether it references a real vendor is left to Prisma's // own FK constraint at insert time (see createLicense()'s P2003 handling // below), rather than an extra pre-check query. Mirrors the same pattern // used for Request.employeeId in src/lib/requests.ts. 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"), seatsTotal: z .number({ required_error: "seatsTotal is required and must be a positive integer", invalid_type_error: "seatsTotal is required and must be a positive integer" }) .int({ message: "seatsTotal is required and must be a positive integer" }) .positive({ message: "seatsTotal is required and must be a positive integer" }), expiryDate: dateString("expiryDate must be a valid date string").optional().nullable(), licenseType: z.enum(["perpetual", "subscription"], { errorMap: () => ({ message: "licenseType is required and must be either 'perpetual' or 'subscription'", }), }), // FK to PurchaseOrder — nullable/optional, supply-side traceability (not // every License goes through a formal PO). Only a UUID *format* check // here, whether it references a real PurchaseOrder is left to Prisma's // own FK constraint at insert time (see createLicense()'s P2003 handling // below). Mirrors src/lib/assets.ts's purchaseOrderId // optional().nullable() pattern exactly. purchaseOrderId: z .string({ invalid_type_error: "purchaseOrderId must be a valid UUID string" }) .uuid("purchaseOrderId must be a valid UUID string") .optional() .nullable(), }); /** * Validates raw (untyped) license-creation input. name must be a non-empty * string, vendorId must be a valid UUID string, seatsTotal must be a * positive integer, expiryDate is optional/nullable but must be a valid * date string if present, licenseType must be one of the two enum values. * seatsUsed is not accepted here — new licenses always start at 0 (same * pattern as AssetItem always starting "in_stock"), seats are only ever * consumed via other operations (once Assignment exists), not at creation. * purchaseOrderId is optional/nullable but must be a valid UUID if present. */ export function validateCreateLicenseInput(input: { name?: unknown; vendorId?: unknown; seatsTotal?: unknown; expiryDate?: unknown; licenseType?: unknown; purchaseOrderId?: unknown; }): ValidationResult { const result = createLicenseSchema.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; name: string; vendorId: string; seatsTotal: number; seatsUsed: number; expiryDate: Date | null; licenseType: LicenseType; purchaseOrderId: string | null; createdAt: Date; }): LicenseRecord { return { id: row.id, name: row.name, vendor_id: row.vendorId, seats_total: row.seatsTotal, seats_used: row.seatsUsed, expiry_date: row.expiryDate ? row.expiryDate.toISOString() : null, license_type: row.licenseType, purchase_order_id: row.purchaseOrderId, created_at: row.createdAt.toISOString(), }; } export type CreateLicenseResult = | { ok: true; value: LicenseRecord } | { ok: false; error: string }; /** * Creates a License. Two separate FKs can fail here (vendorId -> Vendor, * purchaseOrderId -> 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/purchase-orders.ts's createPurchaseOrder() pattern for * disambiguating between two FKs on the same model. */ export async function createLicense(input: CreateLicenseInput): Promise { try { const created = await prisma.license.create({ data: { name: input.name, vendorId: input.vendorId, seatsTotal: input.seatsTotal, expiryDate: input.expiryDate ?? null, licenseType: input.licenseType, purchaseOrderId: input.purchaseOrderId ?? 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: "purchaseOrderId does not reference an existing purchase order" }; } return { ok: false, error: "vendorId does not reference an existing vendor" }; } throw error; } } export async function listLicenses(): Promise { const rows = await prisma.license.findMany({ orderBy: [{ createdAt: "desc" }, { id: "desc" }], }); return rows.map(toRecord); }