feat(driver): serve proof of payment for assigned POs
This commit is contained in:
parent
fc497fad96
commit
a503bd55ff
4 changed files with 176 additions and 0 deletions
39
src/app/api/v1/driver/payment-proof/[poId]/route.test.ts
Normal file
39
src/app/api/v1/driver/payment-proof/[poId]/route.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
38
src/app/api/v1/driver/payment-proof/[poId]/route.ts
Normal file
38
src/app/api/v1/driver/payment-proof/[poId]/route.ts
Normal file
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
45
src/lib/server/driver-payment-proof-service.test.ts
Normal file
45
src/lib/server/driver-payment-proof-service.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
54
src/lib/server/driver-payment-proof-service.ts
Normal file
54
src/lib/server/driver-payment-proof-service.ts
Normal file
|
|
@ -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<ReturnType<typeof getAdminPocketBase>>;
|
||||
type Proof = { requestId: string; recordId: string; filename: string };
|
||||
|
||||
async function listAssignedProofs(pb: Pb, poId: string, driverId: string): Promise<Proof[]> {
|
||||
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<SupplierPaymentRequest>(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" };
|
||||
}
|
||||
Loading…
Reference in a new issue