diff --git a/prisma/migrations/20260803002253_add_request_purchase_order_id/migration.sql b/prisma/migrations/20260803002253_add_request_purchase_order_id/migration.sql new file mode 100644 index 0000000..0678d2f --- /dev/null +++ b/prisma/migrations/20260803002253_add_request_purchase_order_id/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c50cc6d..63c5938 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,21 +26,30 @@ enum RequestType { } model Request { - id String @id @default(uuid()) @db.Uuid + id String @id @default(uuid()) @db.Uuid // Was a free-text String with no referential integrity — retrofitted to a // real FK once Employee records existed to point to (see Employee model // above). No production data existed at the time of this change (`requests` // table was empty), so this was a plain column-type migration, not a // backfill. - employeeId String @map("employee_id") @db.Uuid - employee Employee @relation(fields: [employeeId], references: [id]) - type RequestType - reason String + employeeId String @map("employee_id") @db.Uuid + employee Employee @relation(fields: [employeeId], references: [id]) + type RequestType + reason String // Only the 'Draft' state is implemented so far. Deliberately kept as a // plain string (not an enum) until more states exist — see // src/lib/requests.ts's RequestStatus type. - status String @default("Draft") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + 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") } diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts index 2d5ddaf..3036de1 100644 --- a/src/app/api/requests/route.ts +++ b/src/app/api/requests/route.ts @@ -24,8 +24,8 @@ export async function POST(request: Request): Promise { return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); } - const { employee_id, type, reason } = body as Record; - const validation = validateCreateRequestInput({ employee_id, type, reason }); + const { employee_id, type, reason, purchase_order_id } = body as Record; + const validation = validateCreateRequestInput({ employee_id, type, reason, purchase_order_id }); if (!validation.ok) { return NextResponse.json({ error: validation.error }, { status: 400 }); diff --git a/src/lib/requests.ts b/src/lib/requests.ts index eff712e..14631ec 100644 --- a/src/lib/requests.ts +++ b/src/lib/requests.ts @@ -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 { try { @@ -111,11 +130,16 @@ export async function createRequest(input: CreateRequestInput): Promise { // 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", () => {