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:
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "licenses" ADD COLUMN "purchase_order_id" UUID;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "licenses" ADD CONSTRAINT "licenses_purchase_order_id_fkey" FOREIGN KEY ("purchase_order_id") REFERENCES "purchase_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+14
-3
@@ -120,7 +120,13 @@ enum LicenseType {
|
||||
// at creation), not a computed value — it will only ever be set to 0 here
|
||||
// since Assignment (the thing that would consume seats) doesn't exist yet.
|
||||
// `expiryDate` is nullable: perpetual licenses never expire, only
|
||||
// subscription licenses usually have one.
|
||||
// subscription licenses usually have one. `purchaseOrderId` is a nullable FK
|
||||
// to PurchaseOrder — see docs/asset-license-management.md's "## PurchaseOrder
|
||||
// (gộp Receipt)" Traceability bullet: supply-side traceability ("which PO
|
||||
// did this license come in through"). Nullable because not every License
|
||||
// goes through a formal PO. Mirrors AssetItem.purchaseOrderId's just-merged
|
||||
// pattern exactly — same nullable-FK shape, set at creation time only, no
|
||||
// update endpoint for this field yet.
|
||||
model License {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
@@ -130,6 +136,8 @@ model License {
|
||||
seatsUsed Int @default(0) @map("seats_used")
|
||||
expiryDate DateTime? @map("expiry_date") @db.Date
|
||||
licenseType LicenseType @map("license_type")
|
||||
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
|
||||
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("licenses")
|
||||
@@ -175,8 +183,10 @@ model Vendor {
|
||||
// DB constraint, same approach as other cross-field invariants in this
|
||||
// codebase (e.g. Assignment's assetItemId/licenseId XOR, not built yet).
|
||||
// `assetItems` is the back-relation from the AssetItem.purchaseOrderId
|
||||
// retrofit (supply-side traceability, same doc section). License/Request
|
||||
// back-references are NOT part of this change — out of scope for this task.
|
||||
// retrofit (supply-side traceability, same doc section). `licenses` is the
|
||||
// back-relation from the License.purchaseOrderId retrofit (same doc
|
||||
// section, same rationale). Request back-references are NOT part of this
|
||||
// change — out of scope for this task.
|
||||
model PurchaseOrder {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
vendorId String @map("vendor_id") @db.Uuid
|
||||
@@ -188,6 +198,7 @@ model PurchaseOrder {
|
||||
receivedBy Employee? @relation(fields: [receivedById], references: [id])
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
assetItems AssetItem[]
|
||||
licenses License[]
|
||||
|
||||
@@map("purchase_orders")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+161
-2
@@ -49,11 +49,29 @@ function createTestVendor(overrides: { name?: string } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// License.purchaseOrderId is a real FK to PurchaseOrder (see schema.prisma /
|
||||
// src/lib/licenses.ts) — tests that need a valid purchaseOrderId create a
|
||||
// real PurchaseOrder row first and use its id. Mirrors
|
||||
// tests/assets.test.ts's createTestPurchaseOrder() helper.
|
||||
function createTestPurchaseOrder(overrides: { vendorId?: string } = {}) {
|
||||
return prisma.purchaseOrder.create({
|
||||
data: {
|
||||
vendorId: overrides.vendorId as string,
|
||||
orderDate: new Date("2026-08-01"),
|
||||
expectedDate: new Date("2026-08-10"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Each test starts from empty tables so listing/order assertions are
|
||||
// deterministic. licenses must be cleared before vendors since
|
||||
// licenses.vendor_id has an FK (ON DELETE RESTRICT) to vendors.
|
||||
// deterministic. licenses must be cleared before purchase_orders/vendors
|
||||
// since licenses has FKs (ON DELETE RESTRICT to vendors, ON DELETE SET
|
||||
// NULL to purchase_orders) to both. purchase_orders must be cleared
|
||||
// before vendors (ON DELETE RESTRICT to vendors, mirrors
|
||||
// tests/assets.test.ts).
|
||||
await prisma.license.deleteMany();
|
||||
await prisma.purchaseOrder.deleteMany();
|
||||
await prisma.vendor.deleteMany();
|
||||
// Default to a logged-in session — the lib-level tests below don't go
|
||||
// through the route at all (so this is a no-op for them), and the
|
||||
@@ -222,6 +240,21 @@ describe("validateCreateLicenseInput", () => {
|
||||
expect(result.error).toMatch(/licenseType/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a purchaseOrderId that is not a valid UUID", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
purchaseOrderId: "not-a-uuid",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/purchaseOrderId/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLicense", () => {
|
||||
@@ -298,6 +331,64 @@ describe("createLicense", () => {
|
||||
const remaining = await prisma.license.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("creates a license with a purchaseOrderId that references a real purchase order", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
purchaseOrderId: purchaseOrder.id,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createLicense(result.value);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.value.purchase_order_id).toBe(purchaseOrder.id);
|
||||
});
|
||||
|
||||
it("creates a license with no purchaseOrderId (stays null)", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createLicense(result.value);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.value.purchase_order_id).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a clean error when purchaseOrderId doesn't reference an existing purchase order", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createLicense(result.value);
|
||||
expect(created.ok).toBe(false);
|
||||
if (created.ok) return;
|
||||
expect(created.error).toMatch(/purchaseOrderId/i);
|
||||
expect(created.error).toMatch(/does not reference an existing purchase order/i);
|
||||
|
||||
const remaining = await prisma.license.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listLicenses", () => {
|
||||
@@ -471,6 +562,74 @@ describe("POST /api/licenses", () => {
|
||||
expect(body.error).toMatch(/licenseType/i);
|
||||
});
|
||||
|
||||
it("creates a license with a purchaseOrderId that references a real purchase order", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
purchaseOrderId: purchaseOrder.id,
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.purchase_order_id).toBe(purchaseOrder.id);
|
||||
});
|
||||
|
||||
it("creates a license with no purchaseOrderId (stays null)", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const res = await POST(
|
||||
postRequest({ name: "Adobe Creative Cloud", vendorId: vendor.id, seatsTotal: 10, licenseType: "subscription" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.purchase_order_id).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a purchaseOrderId that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/purchaseOrderId/i);
|
||||
expect(body.error).toMatch(/does not reference an existing purchase order/i);
|
||||
|
||||
// And it must not have created anything.
|
||||
const remaining = await prisma.license.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a purchaseOrderId that is not a valid UUID", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
purchaseOrderId: "not-a-uuid",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/purchaseOrderId/i);
|
||||
});
|
||||
|
||||
it("rejects an invalid JSON body", async () => {
|
||||
const req = new Request("http://localhost/api/licenses", {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user