diff --git a/src/app/api/v1/driver/payment-proof/[poId]/route.test.ts b/src/app/api/v1/driver/payment-proof/[poId]/route.test.ts new file mode 100644 index 0000000..f39f24d --- /dev/null +++ b/src/app/api/v1/driver/payment-proof/[poId]/route.test.ts @@ -0,0 +1,39 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { fetchDriverPaymentProof, listDriverPaymentProofs } from "@/lib/server/driver-payment-proof-service"; +import { GET } from "./route"; + +vi.mock("@/lib/server/browser-session", () => ({ getBrowserSessionUser: vi.fn() })); +vi.mock("@/lib/server/driver-payment-proof-service", () => ({ + fetchDriverPaymentProof: vi.fn(), + listDriverPaymentProofs: vi.fn(), +})); + +const url = "http://localhost/api/v1/driver/payment-proof/po1"; +const ctx = { params: Promise.resolve({ poId: "po1" }) }; + +beforeEach(() => vi.resetAllMocks()); + +describe("driver payment proof route", () => { + it("requires the driver session before looking up a proof file", async () => { + vi.mocked(getBrowserSessionUser).mockRejectedValue(Object.assign(new Error(), { status: 401 })); + + expect((await GET(new NextRequest(`${url}?payment=request1`), ctx)).status).toBe(401); + expect(fetchDriverPaymentProof).not.toHaveBeenCalled(); + }); + + it("returns only payment identifiers and checks access again when streaming the file", async () => { + vi.mocked(getBrowserSessionUser).mockResolvedValue({ id: "driver1", name: "Driver", user_type: null }); + vi.mocked(listDriverPaymentProofs).mockResolvedValue([{ requestId: "request1", recordId: "line1", filename: "proof.pdf" }]); + vi.mocked(fetchDriverPaymentProof).mockResolvedValue({ body: new Response("test PDF").body!, contentType: "application/pdf" }); + + const list = await GET(new NextRequest(url), ctx); + expect(await list.json()).toEqual({ payments: ["request1"] }); + const file = await GET(new NextRequest(`${url}?payment=request1`), ctx); + expect(fetchDriverPaymentProof).toHaveBeenCalledWith("po1", "driver1", "request1"); + expect(file.headers.get("cache-control")).toBe("private, no-store"); + expect(file.headers.get("content-type")).toBe("application/pdf"); + expect(await file.text()).toBe("test PDF"); + }); +}); diff --git a/src/app/api/v1/driver/payment-proof/[poId]/route.ts b/src/app/api/v1/driver/payment-proof/[poId]/route.ts new file mode 100644 index 0000000..c61fd44 --- /dev/null +++ b/src/app/api/v1/driver/payment-proof/[poId]/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { fetchDriverPaymentProof, listDriverPaymentProofs } from "@/lib/server/driver-payment-proof-service"; + +const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Authorization", + "Cache-Control": "private, no-store", +}; + +export function OPTIONS() { + return new NextResponse(null, { status: 204, headers }); +} + +export async function GET(req: NextRequest, { params }: { params: Promise<{ poId: string }> }) { + try { + const user = await getBrowserSessionUser(req); + const { poId } = await params; + const requestId = req.nextUrl.searchParams.get("payment"); + if (requestId === null) { + const proofs = await listDriverPaymentProofs(poId, user.id); + return NextResponse.json({ payments: proofs.map((proof) => proof.requestId) }, { headers }); + } + + const file = await fetchDriverPaymentProof(poId, user.id, requestId); + if (!file) return NextResponse.json({ error: "Proof of payment is unavailable." }, { status: 404, headers }); + return new NextResponse(file.body, { + headers: { ...headers, "Content-Type": file.contentType, "X-Content-Type-Options": "nosniff" }, + }); + } catch (error) { + const unauthorized = (error as { status?: number } | null)?.status === 401; + return NextResponse.json( + { error: unauthorized ? "Sign in to view proof of payment." : "Could not load proof of payment." }, + { status: unauthorized ? 401 : 500, headers }, + ); + } +} diff --git a/src/lib/server/driver-payment-proof-service.test.ts b/src/lib/server/driver-payment-proof-service.test.ts new file mode 100644 index 0000000..f50219a --- /dev/null +++ b/src/lib/server/driver-payment-proof-service.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { listDriverPaymentProofs } from "./driver-payment-proof-service"; + +const { getFullList, getOne, filter } = vi.hoisted(() => ({ + getFullList: vi.fn(), + getOne: vi.fn(), + filter: vi.fn(), +})); + +vi.mock("./pocketbase-admin", () => ({ + getAdminPocketBase: async () => ({ + filter, + collection: (name: string) => name === "order_items" ? { getFullList } : { getOne }, + }), +})); + +describe("driver payment proofs", () => { + it("uses only paid, attached evidence on assigned PO lines and lists a shared payment once", async () => { + const line = { order: "order1", supplier: "supplier1", payment_status: "paid", payment_request: "request1", order_item_attachements: ["proof.pdf"] }; + getFullList.mockResolvedValue([{ ...line, id: "line1" }, { ...line, id: "line2" }]); + getOne.mockResolvedValue({ + id: "request1", status: "paid", order: "order1", supplier: "supplier1", + items: ["line1", "line2", "other"], + documents: [ + { role: "proof", collection: "order_items", recordId: "other", filename: "private.pdf" }, + { role: "support", collection: "order_items", recordId: "line1", filename: "proof.pdf" }, + { role: "proof", collection: "order_items", recordId: "line1", filename: "missing.pdf" }, + { role: "proof", collection: "order_items", recordId: "line2", filename: "proof.pdf" }, + ], + }); + + expect(await listDriverPaymentProofs("po1", "driver1")).toEqual([ + { requestId: "request1", recordId: "line2", filename: "proof.pdf" }, + ]); + expect(filter).toHaveBeenCalledWith(expect.stringContaining('drv_id = {:driverId}'), { poId: "po1", driverId: "driver1" }); + expect(getOne).toHaveBeenCalledTimes(1); + }); + + it("returns nothing if the driver has no matching assigned lines", async () => { + getOne.mockClear(); + getFullList.mockResolvedValue([]); + expect(await listDriverPaymentProofs("po1", "other-driver")).toEqual([]); + expect(getOne).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/server/driver-payment-proof-service.ts b/src/lib/server/driver-payment-proof-service.ts new file mode 100644 index 0000000..56b2809 --- /dev/null +++ b/src/lib/server/driver-payment-proof-service.ts @@ -0,0 +1,54 @@ +import type { SupplierPaymentRequest } from "@/lib/order-payments"; +import { attachmentList } from "./order-item-files"; +import { getAdminPocketBase } from "./pocketbase-admin"; + +type Pb = Awaited>; +type Proof = { requestId: string; recordId: string; filename: string }; + +async function listAssignedProofs(pb: Pb, poId: string, driverId: string): Promise { + const lines = await pb.collection("order_items").getFullList({ + filter: pb.filter('purchase_order = {:poId} && drv_id = {:driverId} && item_status = "assigned"', { poId, driverId }), + fields: "id,order,supplier,payment_status,payment_request,order_item_attachements", + }); + const paidLines = new Map(lines.filter((line) => line.payment_status === "paid" && line.payment_request).map((line) => [line.id, line])); + const requestIds = new Set([...paidLines.values()].map((line) => String(line.payment_request))); + const proofs = await Promise.all([...requestIds].map(async (id) => { + const request = await pb.collection("supplier_payment_requests").getOne(id, { + fields: "id,status,order,supplier,items,documents", + }).catch((error: unknown) => { + if ((error as { status?: number } | null)?.status === 404) return null; + throw error; + }); + if (request?.status !== "paid") return null; + + const document = (request.documents ?? []).find((doc) => { + if (doc.role !== "proof" || doc.collection !== "order_items" || !request.items.includes(doc.recordId)) return false; + const line = paidLines.get(doc.recordId); + return line?.payment_request === id && line.order === request.order && line.supplier === request.supplier + && attachmentList(line.order_item_attachements).includes(doc.filename); + }); + return document ? { requestId: request.id, recordId: document.recordId, filename: document.filename } : null; + })); + return proofs.filter((proof): proof is Proof => proof !== null); +} + +export async function listDriverPaymentProofs(poId: string, driverId: string) { + return listAssignedProofs(await getAdminPocketBase(), poId, driverId); +} + +export async function fetchDriverPaymentProof(poId: string, driverId: string, requestId: string) { + const pb = await getAdminPocketBase(); + const proofs = await listAssignedProofs(pb, poId, driverId); + const proof = proofs.find((entry) => entry.requestId === requestId); + if (!proof) return null; + + const token = await pb.files.getToken(); + const url = pb.files.getURL({ collectionName: "order_items", id: proof.recordId }, proof.filename, { token }); + const response = await fetch(url, { cache: "no-store" }); + if (response.status === 404) return null; + if (!response.ok || !response.body) throw new Error("Could not open proof of payment."); + + const type = (response.headers.get("content-type") ?? "").split(";")[0].toLowerCase(); + const preview = /^(application\/pdf|image\/(jpeg|png|gif|webp|avif|bmp|heic|heif))$/.test(type); + return { body: response.body, contentType: preview ? type : "application/octet-stream" }; +}