feat: add License.purchaseOrderId (nullable FK)

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.
This commit is contained in:
toanhoang
2026-08-02 20:17:09 -04:00
parent fb52f473a5
commit abec58d239
5 changed files with 221 additions and 20 deletions
+3 -1
View File
@@ -22,13 +22,15 @@ export async function POST(request: Request): Promise<NextResponse> {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { name, vendorId, seatsTotal, expiryDate, licenseType } = body as Record<string, unknown>;
const { name, vendorId, seatsTotal, expiryDate, licenseType, purchaseOrderId } =
body as Record<string, unknown>;
const validation = validateCreateLicenseInput({
name,
vendorId,
seatsTotal,
expiryDate,
licenseType,
purchaseOrderId,
});
if (!validation.ok) {
+29 -5
View File
@@ -12,6 +12,7 @@ export interface LicenseRecord {
seats_used: number;
expiry_date: string | null;
license_type: LicenseType;
purchase_order_id: string | null;
created_at: string;
}
@@ -21,6 +22,7 @@ export interface CreateLicenseInput {
seatsTotal: number;
expiryDate?: Date | null;
licenseType: LicenseType;
purchaseOrderId?: string | null;
}
export type ValidationResult =
@@ -65,6 +67,17 @@ const createLicenseSchema = z.object({
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(),
});
/**
@@ -75,6 +88,7 @@ const createLicenseSchema = z.object({
* 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;
@@ -82,6 +96,7 @@ export function validateCreateLicenseInput(input: {
seatsTotal?: unknown;
expiryDate?: unknown;
licenseType?: unknown;
purchaseOrderId?: unknown;
}): ValidationResult {
const result = createLicenseSchema.safeParse(input);
if (!result.success) {
@@ -99,6 +114,7 @@ function toRecord(row: {
seatsUsed: number;
expiryDate: Date | null;
licenseType: LicenseType;
purchaseOrderId: string | null;
createdAt: Date;
}): LicenseRecord {
return {
@@ -109,6 +125,7 @@ function toRecord(row: {
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(),
};
}
@@ -118,11 +135,13 @@ export type CreateLicenseResult =
| { ok: false; error: string };
/**
* Creates a License. If vendorId doesn't reference an existing Vendor,
* Prisma raises a known error (code P2003, FK violation) — caught here and
* translated into the same `{ok:false,error}` shape as input validation
* failures, rather than letting a raw Prisma error leak to the API layer.
* Mirrors src/lib/requests.ts's createRequest() pattern for employeeId.
* 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 {
@@ -133,11 +152,16 @@ export async function createLicense(input: CreateLicenseInput): Promise<CreateLi
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;