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:
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user