2 Commits
Author SHA1 Message Date
toanhoangandClaude Sonnet 5 3dd1b22828 Merge branch 'feat/license-po-id'
Add nullable License.purchaseOrderId FK (supply-side PO
traceability). Verified green by test-runner (139/139 tests,
tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 20:20:58 -04:00
toanhoang abec58d239 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.
2026-08-02 20:17:09 -04:00
5 changed files with 221 additions and 20 deletions
@@ -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;
+23 -12
View File
@@ -120,17 +120,25 @@ enum LicenseType {
// at creation), not a computed value — it will only ever be set to 0 here // 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. // since Assignment (the thing that would consume seats) doesn't exist yet.
// `expiryDate` is nullable: perpetual licenses never expire, only // `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 { model License {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
name String name String
vendorId String @map("vendor_id") @db.Uuid vendorId String @map("vendor_id") @db.Uuid
vendor Vendor @relation(fields: [vendorId], references: [id]) vendor Vendor @relation(fields: [vendorId], references: [id])
seatsTotal Int @map("seats_total") seatsTotal Int @map("seats_total")
seatsUsed Int @default(0) @map("seats_used") seatsUsed Int @default(0) @map("seats_used")
expiryDate DateTime? @map("expiry_date") @db.Date expiryDate DateTime? @map("expiry_date") @db.Date
licenseType LicenseType @map("license_type") licenseType LicenseType @map("license_type")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) 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") @@map("licenses")
} }
@@ -175,8 +183,10 @@ model Vendor {
// DB constraint, same approach as other cross-field invariants in this // DB constraint, same approach as other cross-field invariants in this
// codebase (e.g. Assignment's assetItemId/licenseId XOR, not built yet). // codebase (e.g. Assignment's assetItemId/licenseId XOR, not built yet).
// `assetItems` is the back-relation from the AssetItem.purchaseOrderId // `assetItems` is the back-relation from the AssetItem.purchaseOrderId
// retrofit (supply-side traceability, same doc section). License/Request // retrofit (supply-side traceability, same doc section). `licenses` is the
// back-references are NOT part of this change — out of scope for this task. // 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 { model PurchaseOrder {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
vendorId String @map("vendor_id") @db.Uuid vendorId String @map("vendor_id") @db.Uuid
@@ -188,6 +198,7 @@ model PurchaseOrder {
receivedBy Employee? @relation(fields: [receivedById], references: [id]) receivedBy Employee? @relation(fields: [receivedById], references: [id])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
assetItems AssetItem[] assetItems AssetItem[]
licenses License[]
@@map("purchase_orders") @@map("purchase_orders")
} }
+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 }); 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({ const validation = validateCreateLicenseInput({
name, name,
vendorId, vendorId,
seatsTotal, seatsTotal,
expiryDate, expiryDate,
licenseType, licenseType,
purchaseOrderId,
}); });
if (!validation.ok) { if (!validation.ok) {
+29 -5
View File
@@ -12,6 +12,7 @@ export interface LicenseRecord {
seats_used: number; seats_used: number;
expiry_date: string | null; expiry_date: string | null;
license_type: LicenseType; license_type: LicenseType;
purchase_order_id: string | null;
created_at: string; created_at: string;
} }
@@ -21,6 +22,7 @@ export interface CreateLicenseInput {
seatsTotal: number; seatsTotal: number;
expiryDate?: Date | null; expiryDate?: Date | null;
licenseType: LicenseType; licenseType: LicenseType;
purchaseOrderId?: string | null;
} }
export type ValidationResult = export type ValidationResult =
@@ -65,6 +67,17 @@ const createLicenseSchema = z.object({
message: "licenseType is required and must be either 'perpetual' or 'subscription'", 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 * seatsUsed is not accepted here — new licenses always start at 0 (same
* pattern as AssetItem always starting "in_stock"), seats are only ever * pattern as AssetItem always starting "in_stock"), seats are only ever
* consumed via other operations (once Assignment exists), not at creation. * 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: { export function validateCreateLicenseInput(input: {
name?: unknown; name?: unknown;
@@ -82,6 +96,7 @@ export function validateCreateLicenseInput(input: {
seatsTotal?: unknown; seatsTotal?: unknown;
expiryDate?: unknown; expiryDate?: unknown;
licenseType?: unknown; licenseType?: unknown;
purchaseOrderId?: unknown;
}): ValidationResult { }): ValidationResult {
const result = createLicenseSchema.safeParse(input); const result = createLicenseSchema.safeParse(input);
if (!result.success) { if (!result.success) {
@@ -99,6 +114,7 @@ function toRecord(row: {
seatsUsed: number; seatsUsed: number;
expiryDate: Date | null; expiryDate: Date | null;
licenseType: LicenseType; licenseType: LicenseType;
purchaseOrderId: string | null;
createdAt: Date; createdAt: Date;
}): LicenseRecord { }): LicenseRecord {
return { return {
@@ -109,6 +125,7 @@ function toRecord(row: {
seats_used: row.seatsUsed, seats_used: row.seatsUsed,
expiry_date: row.expiryDate ? row.expiryDate.toISOString() : null, expiry_date: row.expiryDate ? row.expiryDate.toISOString() : null,
license_type: row.licenseType, license_type: row.licenseType,
purchase_order_id: row.purchaseOrderId,
created_at: row.createdAt.toISOString(), created_at: row.createdAt.toISOString(),
}; };
} }
@@ -118,11 +135,13 @@ export type CreateLicenseResult =
| { ok: false; error: string }; | { ok: false; error: string };
/** /**
* Creates a License. If vendorId doesn't reference an existing Vendor, * Creates a License. Two separate FKs can fail here (vendorId -> Vendor,
* Prisma raises a known error (code P2003, FK violation) — caught here and * purchaseOrderId -> PurchaseOrder) — Prisma raises the same error code
* translated into the same `{ok:false,error}` shape as input validation * (P2003) for either, so the FK constraint name in `error.meta.field_name`
* failures, rather than letting a raw Prisma error leak to the API layer. * is used to tell them apart and return a targeted message, rather than a
* Mirrors src/lib/requests.ts's createRequest() pattern for employeeId. * 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> { export async function createLicense(input: CreateLicenseInput): Promise<CreateLicenseResult> {
try { try {
@@ -133,11 +152,16 @@ export async function createLicense(input: CreateLicenseInput): Promise<CreateLi
seatsTotal: input.seatsTotal, seatsTotal: input.seatsTotal,
expiryDate: input.expiryDate ?? null, expiryDate: input.expiryDate ?? null,
licenseType: input.licenseType, licenseType: input.licenseType,
purchaseOrderId: input.purchaseOrderId ?? null,
}, },
}); });
return { ok: true, value: toRecord(created) }; return { ok: true, value: toRecord(created) };
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { 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" }; return { ok: false, error: "vendorId does not reference an existing vendor" };
} }
throw error; throw error;
+161 -2
View File
@@ -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 () => { beforeEach(async () => {
// Each test starts from empty tables so listing/order assertions are // Each test starts from empty tables so listing/order assertions are
// deterministic. licenses must be cleared before vendors since // deterministic. licenses must be cleared before purchase_orders/vendors
// licenses.vendor_id has an FK (ON DELETE RESTRICT) to 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.license.deleteMany();
await prisma.purchaseOrder.deleteMany();
await prisma.vendor.deleteMany(); await prisma.vendor.deleteMany();
// Default to a logged-in session — the lib-level tests below don't go // 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 // 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); 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", () => { describe("createLicense", () => {
@@ -298,6 +331,64 @@ describe("createLicense", () => {
const remaining = await prisma.license.findMany(); const remaining = await prisma.license.findMany();
expect(remaining).toHaveLength(0); 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", () => { describe("listLicenses", () => {
@@ -471,6 +562,74 @@ describe("POST /api/licenses", () => {
expect(body.error).toMatch(/licenseType/i); 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 () => { it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/licenses", { const req = new Request("http://localhost/api/licenses", {
method: "POST", method: "POST",