Merge branch 'feat/request-po-id'
Add nullable Request.purchaseOrderId FK (demand-side PO traceability) — closes out Lane A (Vendor -> License.vendorId -> PurchaseOrder -> purchaseOrderId x3). Verified green by test-runner (143/143 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 "requests" ADD COLUMN "purchase_order_id" UUID;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "requests" ADD CONSTRAINT "requests_purchase_order_id_fkey" FOREIGN KEY ("purchase_order_id") REFERENCES "purchase_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+15
-2
@@ -40,6 +40,15 @@ model Request {
|
||||
// plain string (not an enum) until more states exist — see
|
||||
// src/lib/requests.ts's RequestStatus type.
|
||||
status String @default("Draft")
|
||||
// Nullable FK to PurchaseOrder — see docs/asset-license-management.md's
|
||||
// "## PurchaseOrder (gộp Receipt)" Traceability bullet. Semantically the
|
||||
// DEMAND side, unlike AssetItem.purchaseOrderId/License.purchaseOrderId
|
||||
// (supply-side intake): this records "which approved Request got grouped
|
||||
// into this PO", not "which PO did this item come in through". Shape is
|
||||
// identical to those two though — nullable FK, set at creation time only,
|
||||
// no update endpoint for this field yet, same P2003-on-insert handling.
|
||||
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("requests")
|
||||
@@ -185,8 +194,11 @@ model Vendor {
|
||||
// `assetItems` is the back-relation from the AssetItem.purchaseOrderId
|
||||
// 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.
|
||||
// section, same rationale). `requests` is the back-relation from the
|
||||
// Request.purchaseOrderId retrofit — the DEMAND side ("which approved
|
||||
// Request got grouped into this PO"), as opposed to assetItems/licenses
|
||||
// which are the SUPPLY side (intake traceability) — see Request model's
|
||||
// comment above for the distinction.
|
||||
model PurchaseOrder {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
vendorId String @map("vendor_id") @db.Uuid
|
||||
@@ -199,6 +211,7 @@ model PurchaseOrder {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
assetItems AssetItem[]
|
||||
licenses License[]
|
||||
requests Request[]
|
||||
|
||||
@@map("purchase_orders")
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { employee_id, type, reason } = body as Record<string, unknown>;
|
||||
const validation = validateCreateRequestInput({ employee_id, type, reason });
|
||||
const { employee_id, type, reason, purchase_order_id } = body as Record<string, unknown>;
|
||||
const validation = validateCreateRequestInput({ employee_id, type, reason, purchase_order_id });
|
||||
|
||||
if (!validation.ok) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
|
||||
+29
-5
@@ -16,6 +16,7 @@ export interface RequestRecord {
|
||||
type: RequestType;
|
||||
reason: string;
|
||||
status: RequestStatus;
|
||||
purchase_order_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -23,6 +24,7 @@ export interface CreateRequestInput {
|
||||
employee_id: string;
|
||||
type: RequestType;
|
||||
reason: string;
|
||||
purchase_order_id?: string | null;
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
@@ -55,6 +57,18 @@ const createRequestSchema = z.object({
|
||||
message: "type is required and must be exactly 'asset' or 'license'",
|
||||
}),
|
||||
}),
|
||||
// Nullable/optional FK to PurchaseOrder — see schema.prisma's comment on
|
||||
// Request.purchaseOrderId for the demand-vs-supply-side distinction from
|
||||
// AssetItem/License's purchaseOrderId. Only a UUID *format* check here,
|
||||
// whether it references a real PurchaseOrder is left to Prisma's own FK
|
||||
// constraint at insert time (see createRequest()'s P2003 handling below).
|
||||
// Mirrors src/lib/assets.ts's purchaseOrderId optional().nullable()
|
||||
// pattern exactly.
|
||||
purchase_order_id: z
|
||||
.string({ invalid_type_error: "purchase_order_id must be a valid UUID string" })
|
||||
.uuid("purchase_order_id must be a valid UUID string")
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -66,6 +80,7 @@ export function validateCreateRequestInput(input: {
|
||||
employee_id?: unknown;
|
||||
type?: unknown;
|
||||
reason?: unknown;
|
||||
purchase_order_id?: unknown;
|
||||
}): ValidationResult {
|
||||
const result = createRequestSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
@@ -81,6 +96,7 @@ function toRecord(row: {
|
||||
type: RequestType;
|
||||
reason: string;
|
||||
status: string;
|
||||
purchaseOrderId: string | null;
|
||||
createdAt: Date;
|
||||
}): RequestRecord {
|
||||
return {
|
||||
@@ -89,6 +105,7 @@ function toRecord(row: {
|
||||
type: row.type,
|
||||
reason: row.reason,
|
||||
status: row.status as RequestStatus,
|
||||
purchase_order_id: row.purchaseOrderId,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -98,11 +115,13 @@ export type CreateRequestResult =
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Creates a Request. If employee_id doesn't reference an existing Employee,
|
||||
* 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/employees.ts's createEmployee() pattern for managerId.
|
||||
* Creates a Request. Two separate FKs can fail here (employee_id ->
|
||||
* Employee, purchase_order_id -> 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/licenses.ts's createLicense() pattern for disambiguating between
|
||||
* two FKs on the same model.
|
||||
*/
|
||||
export async function createRequest(input: CreateRequestInput): Promise<CreateRequestResult> {
|
||||
try {
|
||||
@@ -111,11 +130,16 @@ export async function createRequest(input: CreateRequestInput): Promise<CreateRe
|
||||
employeeId: input.employee_id,
|
||||
type: input.type,
|
||||
reason: input.reason,
|
||||
purchaseOrderId: input.purchase_order_id ?? 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: "purchase_order_id does not reference an existing purchase order" };
|
||||
}
|
||||
return { ok: false, error: "employee_id does not reference an existing employee" };
|
||||
}
|
||||
throw error;
|
||||
|
||||
+100
-2
@@ -50,12 +50,44 @@ function createTestEmployee(overrides: { name?: string; department?: string } =
|
||||
});
|
||||
}
|
||||
|
||||
// Request.purchaseOrderId is a real FK to PurchaseOrder (see schema.prisma /
|
||||
// src/lib/requests.ts) — tests that need a valid purchase_order_id create a
|
||||
// real PurchaseOrder row (and the Vendor it requires) first and use its id.
|
||||
// Mirrors tests/licenses.test.ts's createTestVendor()/createTestPurchaseOrder()
|
||||
// helpers.
|
||||
function createTestVendor(overrides: { name?: string } = {}) {
|
||||
return prisma.vendor.create({
|
||||
data: {
|
||||
name: overrides.name ?? "Test Vendor",
|
||||
contactEmail: "[email protected]",
|
||||
contactPhone: "0281234567",
|
||||
address: "123 Test Street",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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. requests must be cleared before employees since
|
||||
// requests.employee_id has an FK (ON DELETE RESTRICT) to employees.
|
||||
// deterministic. requests must be cleared before employees/purchase_orders
|
||||
// since requests.employee_id has an FK (ON DELETE RESTRICT) to employees
|
||||
// and requests.purchase_order_id has an FK (ON DELETE SET NULL) to
|
||||
// purchase_orders. purchase_orders must be cleared before employees/vendors
|
||||
// (ON DELETE RESTRICT to vendors, mirrors tests/licenses.test.ts) — order:
|
||||
// requests -> purchase_orders -> employees -> vendors.
|
||||
await prisma.request.deleteMany();
|
||||
await prisma.purchaseOrder.deleteMany();
|
||||
await prisma.employee.deleteMany();
|
||||
await prisma.vendor.deleteMany();
|
||||
// Default to a logged-in session — the tests below exercise request
|
||||
// business logic, not auth gating (that's covered by the "auth gating"
|
||||
// describe block further down).
|
||||
@@ -141,6 +173,72 @@ describe("POST /api/requests", () => {
|
||||
const remaining = await prisma.request.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("creates a request with a purchase_order_id that references a real purchase order", async () => {
|
||||
const employee = await createTestEmployee();
|
||||
const vendor = await createTestVendor();
|
||||
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
employee_id: employee.id,
|
||||
type: "asset",
|
||||
reason: "New laptop for onboarding",
|
||||
purchase_order_id: purchaseOrder.id,
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.purchase_order_id).toBe(purchaseOrder.id);
|
||||
});
|
||||
|
||||
it("creates a request with no purchase_order_id (stays null)", async () => {
|
||||
const employee = await createTestEmployee();
|
||||
const res = await POST(
|
||||
postRequest({ employee_id: employee.id, type: "asset", reason: "New laptop for onboarding" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.purchase_order_id).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a purchase_order_id that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
|
||||
const employee = await createTestEmployee();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
employee_id: employee.id,
|
||||
type: "asset",
|
||||
reason: "New laptop for onboarding",
|
||||
purchase_order_id: "00000000-0000-0000-0000-000000000000",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/purchase_order_id/i);
|
||||
expect(body.error).toMatch(/does not reference an existing purchase order/i);
|
||||
|
||||
// And it must not have created anything.
|
||||
const remaining = await prisma.request.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a purchase_order_id that is not a valid UUID", async () => {
|
||||
const employee = await createTestEmployee();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
employee_id: employee.id,
|
||||
type: "asset",
|
||||
reason: "New laptop for onboarding",
|
||||
purchase_order_id: "not-a-uuid",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/purchase_order_id/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/requests", () => {
|
||||
|
||||
Reference in New Issue
Block a user