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 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-05-21 19:15:04 -04:00
parent 611f4ed5a6
commit 0f78fbe27e

View File

@ -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' })
}