Nullable FK from License to PurchaseOrder for supply-side traceability (which PO a license came in through), mirroring AssetItem.purchaseOrderId's just-merged pattern exactly. Separate commit from the earlier License.vendorId retrofit per the project's 1-commit-per-business-change convention.
177 lines
6.5 KiB
TypeScript
177 lines
6.5 KiB
TypeScript
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<CreateLicenseResult> {
|
|
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<LicenseRecord[]> {
|
|
const rows = await prisma.license.findMany({
|
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
|
});
|
|
return rows.map(toRecord);
|
|
}
|