From 0f78fbe27e7da5fb10458c470aa407f9730c8159 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 21 May 2026 19:15:04 -0400 Subject: [PATCH] fix(hub/campaigns): move /templates routes above the /:id wildcard The /campaigns/:id GET handler uses a wildcard regex /^\/campaigns\/([^/]+)$/ which captures "templates" as a fake campaign id and returns 404 before the fixed /campaigns/templates routes get a chance to match. Reorder the handle() chain so the fixed paths (/templates, /webhook) come first, then the wildcard :id routes. Add a comment block calling out the ordering requirement so future endpoints don't reintroduce the bug. Verified live: GET /campaigns/templates returns the editable list, GET /campaigns/templates/gift-email-fr still returns the HTML. Co-Authored-By: Claude Opus 4.7 --- services/targo-hub/lib/campaigns.js | 68 ++++++++++++++++------------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js index fe16e09..ef7d3fb 100644 --- a/services/targo-hub/lib/campaigns.js +++ b/services/targo-hub/lib/campaigns.js @@ -637,38 +637,10 @@ async function handle (req, res, method, path) { return json(res, 200, { campaigns: listCampaigns() }) } - // GET /campaigns/:id — full detail - const detailMatch = path.match(/^\/campaigns\/([^/]+)$/) - if (detailMatch && method === 'GET') { - const c = loadCampaign(detailMatch[1]) - if (!c) return json(res, 404, { error: 'not found' }) - return json(res, 200, c) - } - - // PATCH /campaigns/:id — update recipients (e.g. exclude rows, edit email) - if (detailMatch && method === 'PATCH') { - const body = await parseBody(req) - const c = loadCampaign(detailMatch[1]) - if (!c) return json(res, 404, { error: 'not found' }) - if (body.name) c.name = body.name - if (body.params) c.params = { ...c.params, ...body.params } - if (Array.isArray(body.recipients)) c.recipients = body.recipients - return json(res, 200, saveCampaign(c)) - } - - // POST /campaigns/:id/send — fire background worker - const sendMatch = path.match(/^\/campaigns\/([^/]+)\/send$/) - if (sendMatch && method === 'POST') { - const id = sendMatch[1] - const c = loadCampaign(id) - if (!c) return json(res, 404, { error: 'not found' }) - if (activeWorkers.has(id)) return json(res, 409, { error: 'already sending' }) - // Fire and forget - setImmediate(() => sendCampaignAsync(id)) - return json(res, 202, { id, status: 'sending' }) - } - // ── Template CRUD (for the GrapesJS editor in the ops UI) ───────────────── + // ORDER MATTERS: these template routes must be BEFORE the /campaigns/:id + // wildcard below, otherwise paths like /campaigns/templates get matched + // by the wildcard as if "templates" were a campaign ID. // GET /campaigns/templates — list editable templates with metadata if (path === '/campaigns/templates' && method === 'GET') { @@ -739,6 +711,40 @@ async function handle (req, res, method, path) { return json(res, 200, { received: events.length, applied }) } + // ── Per-campaign wildcard routes (MUST stay below the /templates and + // /webhook fixed paths above, otherwise the wildcard captures them) ───── + + // GET /campaigns/:id — full detail + const detailMatch = path.match(/^\/campaigns\/([^/]+)$/) + if (detailMatch && method === 'GET') { + const c = loadCampaign(detailMatch[1]) + if (!c) return json(res, 404, { error: 'not found' }) + return json(res, 200, c) + } + + // PATCH /campaigns/:id — update recipients (e.g. exclude rows, edit email) + if (detailMatch && method === 'PATCH') { + const body = await parseBody(req) + const c = loadCampaign(detailMatch[1]) + if (!c) return json(res, 404, { error: 'not found' }) + if (body.name) c.name = body.name + if (body.params) c.params = { ...c.params, ...body.params } + if (Array.isArray(body.recipients)) c.recipients = body.recipients + return json(res, 200, saveCampaign(c)) + } + + // POST /campaigns/:id/send — fire background worker + const sendMatch = path.match(/^\/campaigns\/([^/]+)\/send$/) + if (sendMatch && method === 'POST') { + const id = sendMatch[1] + const c = loadCampaign(id) + if (!c) return json(res, 404, { error: 'not found' }) + if (activeWorkers.has(id)) return json(res, 409, { error: 'already sending' }) + // Fire and forget + setImmediate(() => sendCampaignAsync(id)) + return json(res, 202, { id, status: 'sending' }) + } + return json(res, 404, { error: 'campaigns endpoint not found' }) }