diff --git a/prisma/migrations/20260803001538_add_license_purchase_order_id/migration.sql b/prisma/migrations/20260803001538_add_license_purchase_order_id/migration.sql new file mode 100644 index 0000000..079e559 --- /dev/null +++ b/prisma/migrations/20260803001538_add_license_purchase_order_id/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 469fa83..c50cc6d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -120,17 +120,25 @@ 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 - vendorId String @map("vendor_id") @db.Uuid - vendor Vendor @relation(fields: [vendorId], references: [id]) - seatsTotal Int @map("seats_total") - seatsUsed Int @default(0) @map("seats_used") - expiryDate DateTime? @map("expiry_date") @db.Date - licenseType LicenseType @map("license_type") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + id String @id @default(uuid()) @db.Uuid + name String + vendorId String @map("vendor_id") @db.Uuid + vendor Vendor @relation(fields: [vendorId], references: [id]) + seatsTotal Int @map("seats_total") + 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") } diff --git a/src/app/api/licenses/route.ts b/src/app/api/licenses/route.ts index 59d56f2..f3431b8 100644 --- a/src/app/api/licenses/route.ts +++ b/src/app/api/licenses/route.ts @@ -22,13 +22,15 @@ export async function POST(request: Request): Promise { return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); } - const { name, vendorId, seatsTotal, expiryDate, licenseType } = body as Record; + const { name, vendorId, seatsTotal, expiryDate, licenseType, purchaseOrderId } = + body as Record; const validation = validateCreateLicenseInput({ name, vendorId, seatsTotal, expiryDate, licenseType, + purchaseOrderId, }); if (!validation.ok) { diff --git a/src/lib/licenses.ts b/src/lib/licenses.ts index 160ed22..2ff0feb 100644 --- a/src/lib/licenses.ts +++ b/src/lib/licenses.ts @@ -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 { try { @@ -133,11 +152,16 @@ export async function createLicense(input: CreateLicenseInput): Promise { // 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",