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:
parent
faaa47a4fe
commit
aea1ab67f3
2 changed files with 156 additions and 0 deletions
138
src/app/api/v1/purchase-orders/[id]/send-email/route.test.ts
Normal file
138
src/app/api/v1/purchase-orders/[id]/send-email/route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue