fix(po-send): push dashboardBaseUrl + outcomeUrl on n8n webhook body

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 <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 11:23:40 +02:00
parent faaa47a4fe
commit aea1ab67f3
2 changed files with 156 additions and 0 deletions

View file

@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(fetchBody.outcomeUrl).toBeUndefined();
expect(fetchBody.dashboardBaseUrl).toBeUndefined();
});
});

View file

@ -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 {