From e232658b3d7fda05e022f82309455204a18fe779 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 08:54:57 -0400 Subject: [PATCH] feat: add License validation + CRUD (src/lib/licenses.ts) --- src/lib/licenses.ts | 131 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 src/lib/licenses.ts diff --git a/src/lib/licenses.ts b/src/lib/licenses.ts new file mode 100644 index 0000000..75ac356 --- /dev/null +++ b/src/lib/licenses.ts @@ -0,0 +1,131 @@ +import { z } from "zod"; +import { prisma } from "./prisma"; + +export type LicenseType = "perpetual" | "subscription"; + +export interface LicenseRecord { + id: string; + name: string; + vendor: string; + seats_total: number; + seats_used: number; + expiry_date: string | null; + license_type: LicenseType; + created_at: string; +} + +export interface CreateLicenseInput { + name: string; + vendor: string; + seatsTotal: number; + expiryDate?: Date | null; + licenseType: LicenseType; +} + +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"), + vendor: nonEmptyString("vendor is required and must be a non-empty 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'", + }), + }), +}); + +/** + * Validates raw (untyped) license-creation input. name/vendor must be + * non-empty strings, 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. + */ +export function validateCreateLicenseInput(input: { + name?: unknown; + vendor?: unknown; + seatsTotal?: unknown; + expiryDate?: unknown; + licenseType?: 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; + vendor: string; + seatsTotal: number; + seatsUsed: number; + expiryDate: Date | null; + licenseType: LicenseType; + createdAt: Date; +}): LicenseRecord { + return { + id: row.id, + name: row.name, + vendor: row.vendor, + seats_total: row.seatsTotal, + seats_used: row.seatsUsed, + expiry_date: row.expiryDate ? row.expiryDate.toISOString() : null, + license_type: row.licenseType, + created_at: row.createdAt.toISOString(), + }; +} + +/** + * Creates a License. No unique constraint exists on `name` (unlike + * AssetItem.serialNumber), so unlike createAssetItem() there's no known + * Prisma error code to catch/translate here. + */ +export async function createLicense(input: CreateLicenseInput): Promise { + const created = await prisma.license.create({ + data: { + name: input.name, + vendor: input.vendor, + seatsTotal: input.seatsTotal, + expiryDate: input.expiryDate ?? null, + licenseType: input.licenseType, + }, + }); + return toRecord(created); +} + +export async function listLicenses(): Promise { + const rows = await prisma.license.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + return rows.map(toRecord); +}