From aea1ab67f3b5daa3c27eee56a7ab5680ea2c9d34 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 3 Aug 2026 11:23:40 +0200 Subject: [PATCH] fix(po-send): push dashboardBaseUrl + outcomeUrl on n8n webhook body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The n8n Code node that posts the SMTP outcome back to the /api/v1/purchase-orders/[id]/send-email/outcome route couldn't read env vars at runtime (N8N_BLOCK_ENV_ACCESS_IN_NODE=true in docker-compose), so it composed the callback URL from fields on the webhook body. The route never pushed dashboardBaseUrl or outcomeUrl, so n8n fell back to a relative URL, the request died, n8n returned HTTP 500, and the route flipped the PO to mail_failed with a misleading '[webhook] Webhook returned HTTP 500' message. DASHBOARD_URL is already defined in .env (labelled 'used by n8n workflows to call back into this app') — the route just never read it. Now the route pushes both the dashboard base and a pre-built absolute outcome URL, trimmed of trailing slashes, and omitted (not empty) when DASHBOARD_URL is unset so the existing 'missing' branch in n8n fires clearly. A new test file asserts all three cases: with DASHBOARD_URL, with a trailing slash, and without the env var. Co-Authored-By: Claude Co-authored-by: Orca --- .../[id]/send-email/route.test.ts | 138 ++++++++++++++++++ .../purchase-orders/[id]/send-email/route.ts | 18 +++ 2 files changed, 156 insertions(+) create mode 100644 src/app/api/v1/purchase-orders/[id]/send-email/route.test.ts diff --git a/src/app/api/v1/purchase-orders/[id]/send-email/route.test.ts b/src/app/api/v1/purchase-orders/[id]/send-email/route.test.ts new file mode 100644 index 0000000..730bf69 --- /dev/null +++ b/src/app/api/v1/purchase-orders/[id]/send-email/route.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; + +const { + sendPurchaseOrderEmailMock, + adminCollectionMock, + fetchMock, +} = vi.hoisted(() => ({ + sendPurchaseOrderEmailMock: vi.fn(), + adminCollectionMock: vi.fn(), + fetchMock: vi.fn(), +})); + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn().mockResolvedValue({ + authStore: { token: "test-pb-token" }, + collection: adminCollectionMock, + }), +})); + +vi.mock("@/lib/server/purchase-orders-service", () => ({ + sendPurchaseOrderEmail: sendPurchaseOrderEmailMock, +})); + +vi.stubGlobal("fetch", fetchMock); + +import { POST } from "./route"; + +const originalEnv = { ...process.env }; + +const basePurchaseOrder = { + id: "po-1", + po_number: "PO-TEST-001", + documents: "po_test_001_v1.pdf", + documents_history: [], + email_payload: { cc: "", bcc: "", subject: "PO", body: "", bodyText: "" }, + additional_attachments: [], + expand: { supplier: { email: "sup@example.com", suppliername: "Acme" } }, +}; + +beforeEach(() => { + process.env.PO_SEND_WORKFLOW_WEBHOOK_URL = "http://n8n.test/webhook"; + process.env.DASHBOARD_URL = "https://dash.test"; + process.env.NEXT_PUBLIC_POCKETBASE_URL = "https://pb.test"; + process.env.AGENT_API_KEY = "test-agent-key"; + sendPurchaseOrderEmailMock.mockReset(); + adminCollectionMock.mockReset(); + fetchMock.mockReset(); +}); + +afterEach(() => { + process.env = { ...originalEnv }; +}); + +describe("POST /api/v1/purchase-orders/[id]/send-email", () => { + it("pushes outcomeUrl and dashboardBaseUrl on the n8n webhook body so the outcome callback can resolve", async () => { + sendPurchaseOrderEmailMock.mockResolvedValue({ + purchaseOrder: basePurchaseOrder, + failure: null, + }); + adminCollectionMock.mockReturnValue({}); + fetchMock.mockResolvedValue({ ok: true, status: 200 }); + + const req = new Request( + "http://test/api/v1/purchase-orders/po-1/send-email", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subject: "PO", message: "msg" }), + }, + ); + await POST(req as never, { + params: Promise.resolve({ id: "po-1" }), + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const fetchInit = fetchMock.mock.calls[0]![1] as { body: string }; + const fetchBody = JSON.parse(fetchInit.body) as Record; + expect(fetchBody.outcomeUrl).toBe( + "https://dash.test/api/v1/purchase-orders/po-1/send-email/outcome", + ); + expect(fetchBody.dashboardBaseUrl).toBe("https://dash.test"); + }); + + it("trims trailing slashes from DASHBOARD_URL so the outcome URL is well-formed", async () => { + process.env.DASHBOARD_URL = "https://dash.test/"; + sendPurchaseOrderEmailMock.mockResolvedValue({ + purchaseOrder: basePurchaseOrder, + failure: null, + }); + adminCollectionMock.mockReturnValue({}); + fetchMock.mockResolvedValue({ ok: true, status: 200 }); + + const req = new Request( + "http://test/api/v1/purchase-orders/po-1/send-email", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subject: "PO", message: "msg" }), + }, + ); + await POST(req as never, { + params: Promise.resolve({ id: "po-1" }), + }); + + const fetchInit = fetchMock.mock.calls[0]![1] as { body: string }; + const fetchBody = JSON.parse(fetchInit.body) as Record; + expect(fetchBody.outcomeUrl).toBe( + "https://dash.test/api/v1/purchase-orders/po-1/send-email/outcome", + ); + }); + + it("omits outcomeUrl and dashboardBaseUrl when DASHBOARD_URL is not set (don't send a malformed URL)", async () => { + delete process.env.DASHBOARD_URL; + sendPurchaseOrderEmailMock.mockResolvedValue({ + purchaseOrder: basePurchaseOrder, + failure: null, + }); + adminCollectionMock.mockReturnValue({}); + fetchMock.mockResolvedValue({ ok: true, status: 200 }); + + const req = new Request( + "http://test/api/v1/purchase-orders/po-1/send-email", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subject: "PO", message: "msg" }), + }, + ); + await POST(req as never, { + params: Promise.resolve({ id: "po-1" }), + }); + + const fetchInit = fetchMock.mock.calls[0]![1] as { body: string }; + const fetchBody = JSON.parse(fetchInit.body) as Record; + expect(fetchBody.outcomeUrl).toBeUndefined(); + expect(fetchBody.dashboardBaseUrl).toBeUndefined(); + }); +}); diff --git a/src/app/api/v1/purchase-orders/[id]/send-email/route.ts b/src/app/api/v1/purchase-orders/[id]/send-email/route.ts index e115049..3bddab0 100644 --- a/src/app/api/v1/purchase-orders/[id]/send-email/route.ts +++ b/src/app/api/v1/purchase-orders/[id]/send-email/route.ts @@ -92,6 +92,16 @@ export async function POST( const pbUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.PB_URL || ""; const webhookUrl = process.env.PO_SEND_WORKFLOW_WEBHOOK_URL; + // The n8n workflow's Code node can't read env vars at runtime + // (N8N_BLOCK_ENV_ACCESS_IN_NODE=true in docker-compose), so it + // composes the outcome-callback URL from fields on the webhook body. + // Push the dashboard base + a pre-built absolute outcome URL so the + // callback never has to construct a URL with a missing host. Trim a + // trailing slash defensively in case DASHBOARD_URL ends with one. + const dashboardBaseUrl = (process.env.DASHBOARD_URL || "").replace(/\/+$/, ""); + const outcomeUrl = dashboardBaseUrl + ? `${dashboardBaseUrl}/api/v1/purchase-orders/${poId}/send-email/outcome` + : undefined; if (!webhookUrl) { const reason = "PO_SEND_WORKFLOW_WEBHOOK_URL is not configured. The cron pass will retry."; const flipped = (await adminPb @@ -209,6 +219,14 @@ export async function POST( // never bites. pbUrl, pbToken: adminPb.authStore.token, + // n8n's outcome-callback URL. The Code node prefers + // `body.outcomeUrl` when present, otherwise it composes the URL + // from `body.dashboardBaseUrl`. Pushing both is belt-and-braces: + // any other n8n workflow that expects `dashboardBaseUrl` works + // too. Omitted (not empty string) when DASHBOARD_URL is unset so + // the Code node's existing "missing" branch fires clearly. + outcomeUrl, + dashboardBaseUrl: dashboardBaseUrl || undefined, }; try {