feat: add Request.purchaseOrderId (nullable FK)
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
|
// plain string (not an enum) until more states exist — see
|
||||||
// src/lib/requests.ts's RequestStatus type.
|
// src/lib/requests.ts's RequestStatus type.
|
||||||
status String @default("Draft")
|
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)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
@@map("requests")
|
@@map("requests")
|
||||||
@@ -185,8 +194,11 @@ model Vendor {
|
|||||||
// `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). `licenses` is the
|
// retrofit (supply-side traceability, same doc section). `licenses` is the
|
||||||
// back-relation from the License.purchaseOrderId retrofit (same doc
|
// back-relation from the License.purchaseOrderId retrofit (same doc
|
||||||
// section, same rationale). Request back-references are NOT part of this
|
// section, same rationale). `requests` is the back-relation from the
|
||||||
// change — out of scope for this task.
|
// 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 {
|
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
|
||||||
@@ -199,6 +211,7 @@ model PurchaseOrder {
|
|||||||
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[]
|
licenses License[]
|
||||||
|
requests Request[]
|
||||||
|
|
||||||
@@map("purchase_orders")
|
@@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 });
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { employee_id, type, reason } = body as Record<string, unknown>;
|
const { employee_id, type, reason, purchase_order_id } = body as Record<string, unknown>;
|
||||||
const validation = validateCreateRequestInput({ employee_id, type, reason });
|
const validation = validateCreateRequestInput({ employee_id, type, reason, purchase_order_id });
|
||||||
|
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||||
|
|||||||
+29
-5
@@ -16,6 +16,7 @@ export interface RequestRecord {
|
|||||||
type: RequestType;
|
type: RequestType;
|
||||||
reason: string;
|
reason: string;
|
||||||
status: RequestStatus;
|
status: RequestStatus;
|
||||||
|
purchase_order_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export interface CreateRequestInput {
|
|||||||
employee_id: string;
|
employee_id: string;
|
||||||
type: RequestType;
|
type: RequestType;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
purchase_order_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ValidationResult =
|
export type ValidationResult =
|
||||||
@@ -55,6 +57,18 @@ const createRequestSchema = z.object({
|
|||||||
message: "type is required and must be exactly 'asset' or 'license'",
|
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;
|
employee_id?: unknown;
|
||||||
type?: unknown;
|
type?: unknown;
|
||||||
reason?: unknown;
|
reason?: unknown;
|
||||||
|
purchase_order_id?: unknown;
|
||||||
}): ValidationResult {
|
}): ValidationResult {
|
||||||
const result = createRequestSchema.safeParse(input);
|
const result = createRequestSchema.safeParse(input);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -81,6 +96,7 @@ function toRecord(row: {
|
|||||||
type: RequestType;
|
type: RequestType;
|
||||||
reason: string;
|
reason: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
purchaseOrderId: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}): RequestRecord {
|
}): RequestRecord {
|
||||||
return {
|
return {
|
||||||
@@ -89,6 +105,7 @@ function toRecord(row: {
|
|||||||
type: row.type,
|
type: row.type,
|
||||||
reason: row.reason,
|
reason: row.reason,
|
||||||
status: row.status as RequestStatus,
|
status: row.status as RequestStatus,
|
||||||
|
purchase_order_id: row.purchaseOrderId,
|
||||||
created_at: row.createdAt.toISOString(),
|
created_at: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -98,11 +115,13 @@ export type CreateRequestResult =
|
|||||||
| { ok: false; error: string };
|
| { ok: false; error: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a Request. If employee_id doesn't reference an existing Employee,
|
* Creates a Request. Two separate FKs can fail here (employee_id ->
|
||||||
* Prisma raises a known error (code P2003, FK violation) — caught here and
|
* Employee, purchase_order_id -> PurchaseOrder) — Prisma raises the same
|
||||||
* translated into the same `{ok:false,error}` shape as input validation
|
* error code (P2003) for either, so the FK constraint name in
|
||||||
* failures, rather than letting a raw Prisma error leak to the API layer.
|
* `error.meta.field_name` is used to tell them apart and return a targeted
|
||||||
* Mirrors src/lib/employees.ts's createEmployee() pattern for managerId.
|
* 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> {
|
export async function createRequest(input: CreateRequestInput): Promise<CreateRequestResult> {
|
||||||
try {
|
try {
|
||||||
@@ -111,11 +130,16 @@ export async function createRequest(input: CreateRequestInput): Promise<CreateRe
|
|||||||
employeeId: input.employee_id,
|
employeeId: input.employee_id,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
|
purchaseOrderId: input.purchase_order_id ?? 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: "purchase_order_id does not reference an existing purchase order" };
|
||||||
|
}
|
||||||
return { ok: false, error: "employee_id does not reference an existing employee" };
|
return { ok: false, error: "employee_id does not reference an existing employee" };
|
||||||
}
|
}
|
||||||
throw error;
|
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 () => {
|
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. requests must be cleared before employees since
|
// deterministic. requests must be cleared before employees/purchase_orders
|
||||||
// requests.employee_id has an FK (ON DELETE RESTRICT) to employees.
|
// 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.request.deleteMany();
|
||||||
|
await prisma.purchaseOrder.deleteMany();
|
||||||
await prisma.employee.deleteMany();
|
await prisma.employee.deleteMany();
|
||||||
|
await prisma.vendor.deleteMany();
|
||||||
// Default to a logged-in session — the tests below exercise request
|
// Default to a logged-in session — the tests below exercise request
|
||||||
// business logic, not auth gating (that's covered by the "auth gating"
|
// business logic, not auth gating (that's covered by the "auth gating"
|
||||||
// describe block further down).
|
// describe block further down).
|
||||||
@@ -141,6 +173,72 @@ describe("POST /api/requests", () => {
|
|||||||
const remaining = await prisma.request.findMany();
|
const remaining = await prisma.request.findMany();
|
||||||
expect(remaining).toHaveLength(0);
|
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", () => {
|
describe("GET /api/requests", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user