feat: add License validation + CRUD (src/lib/licenses.ts)

This commit is contained in:
toanhoang
2026-08-02 08:54:57 -04:00
parent 96088421ba
commit e232658b3d
+131
View File
@@ -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<LicenseRecord> {
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<LicenseRecord[]> {
const rows = await prisma.license.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}