feat: add License validation + CRUD (src/lib/licenses.ts)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user