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]>
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
|
// 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
|
||||||
@@ -130,6 +136,8 @@ model License {
|
|||||||
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")
|
||||||
|
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)
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user