gigafibre-fsm/services/targo-hub/lib/campaigns.js
louispaulb 0f78fbe27e 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>
2026-05-21 19:15:04 -04:00

758 lines
31 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict'
// ─────────────────────────────────────────────────────────────────────────────
// Gift campaigns — backend for the ops UI.
//
// One campaign = one Mailjet blast of personalized French emails containing a
// Giftbit shortlink. Two CSVs come in (raw Map export + Giftbit shortlinks),
// we match each contact to an ERPNext Customer (email / phone / civic address),
// store the send list as JSON, and run the send in the background with live
// progress over SSE.
//
// Storage layout:
// data/campaigns/<campaign-id>.json
// {
// id, name, created_at, status: draft|sending|completed|failed,
// params: { amount, expiry, commitment_months, subject, from, template,
// throttle_ms, smtp_host, smtp_port },
// counters: { total, sent, failed, queued, opened, clicked, bounced },
// recipients: [{
// firstname, lastname, email, phone, civic_address, postal_code,
// gift_url, giftbit_uuid, gift_value_cents,
// customer_id, # null if unmatched
// customer_name, # display name from ERPNext
// match_method, # 'email' | 'phone' | 'civic' | null
// match_confidence, # 1.0 (exact) | 0.8 (postal+civic) | 0 (none)
// status, # pending | queued | sent | failed | clicked | bounced
// mailjet_uuid, # set when sent (used by webhook to update)
// error, sent_at, opened_at, clicked_at,
// excluded # true = skip on send
// }]
// }
//
// SSE topic: 'campaign:<id>' — emits 'recipient-update' on every status change
// and 'campaign-done' when send finishes.
// ─────────────────────────────────────────────────────────────────────────────
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const cfg = require('./config')
const { log, json, parseBody } = require('./helpers')
const erp = require('./erp')
const email = require('./email')
const sse = require('./sse')
const DATA_DIR = path.join(__dirname, '..', 'data', 'campaigns')
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
// ── CSV utilities ────────────────────────────────────────────────────────────
// Same RFC-4180-ish parser as the CLI scripts. Handles quoted fields with
// embedded delimiters and escaped double-quotes. Delimiter auto-detect
// (comma / tab / pipe) based on the first line.
function parseCsv (text, opts = {}) {
const skipPreamble = !!opts.skipPreamble
let work = text.replace(/^/, '') // strip BOM
if (skipPreamble) work = work.replace(/^[^\n]*\n/, '') // drop title line
const sample = work.split(/\r?\n/, 1)[0] || ''
const delim = sample.includes('|') ? '|'
: sample.includes('\t') ? '\t'
: ','
const rows = []; let row = [], field = '', inQ = false
for (let i = 0; i < work.length; i++) {
const c = work[i]
if (inQ) {
if (c === '"' && work[i + 1] === '"') { field += '"'; i++ }
else if (c === '"') inQ = false
else field += c
} else {
if (c === '"') inQ = true
else if (c === delim) { row.push(field); field = '' }
else if (c === '\n' || c === '\r') {
if (field !== '' || row.length) { row.push(field); rows.push(row); row = []; field = '' }
if (c === '\r' && work[i + 1] === '\n') i++
} else field += c
}
}
if (field !== '' || row.length) { row.push(field); rows.push(row) }
if (!rows.length) return []
const header = rows[0].map(h => h.trim())
return rows.slice(1).filter(r => r.some(c => c !== '')).map(r => {
const o = {}; header.forEach((h, i) => { o[h] = (r[i] || '').trim() }); return o
})
}
// ── Address / name normalization (mirrors contacts_from_legacy.py) ──────────
const LOWER_WORDS = new Set(['de','du','des','la','le','les','au','aux','à',
'et','sur','en'])
const EMAIL_SPLIT = /\s*[;,]\s*/
function titleAddress (addr) {
if (!addr) return ''
return addr.split(/\s+/).map((word, i) => {
const lw = word.toLowerCase()
if (i > 0 && LOWER_WORDS.has(lw)) return lw
if (word.includes('-')) {
return word.split('-').map(c => {
const cl = c.toLowerCase()
return LOWER_WORDS.has(cl) ? cl : (c[0] || '').toUpperCase() + c.slice(1).toLowerCase()
}).join('-')
}
return (word[0] || '').toUpperCase() + word.slice(1).toLowerCase()
}).join(' ')
}
function normalizeEmail (raw) {
return (raw || '').trim().toLowerCase()
}
// Strip non-digit chars; treat as Canadian 10-digit if longer than 10
function normalizePhone (raw) {
const digits = (raw || '').replace(/\D/g, '')
if (digits.length === 11 && digits.startsWith('1')) return digits.slice(1)
if (digits.length === 10) return digits
return digits || null
}
// Canadian postal H1A 1B1 — uppercase, no internal space
function normalizePostal (raw) {
return (raw || '').replace(/\s+/g, '').toUpperCase().replace(/^([A-Z]\d[A-Z])(\d[A-Z]\d)$/, '$1 $2')
}
// Civic = numeric prefix + first non-empty street word. We compare prefix +
// first 5 chars of street to avoid false-positives from "Rue" vs "Route".
function normalizeCivic (addr) {
if (!addr) return null
const m = addr.trim().match(/^(\d+[A-Za-z]?)\s+(.+)$/)
if (!m) return null
const num = m[1].toUpperCase()
// Strip "Rue", "Route", "Boulevard" etc. — they're noise for matching
const street = m[2].replace(/^(rue|route|boulevard|boul\.?|avenue|av\.?|chemin|ch\.?|place|pl\.?)\s+/i, '').trim()
return num + '|' + street.toLowerCase().slice(0, 12)
}
// ── Map CSV parsing — port of contacts_from_legacy.py ────────────────────────
// The legacy export has a 1-line title preamble + pipe-delimited columns.
// Columns we care about:
// "nom au compte" — billing contact name (preferred)
// "nom à l'adresse" — service-address name (fallback)
// "email au compte" — billing email (preferred)
// "email à l'adresse" — service-address email (fallback)
// "telephone au compte" or "telephone à l'adresse" — phone for matching
// "adresse dans F" — street address
// "code postal au compte" or "code postal à l'adresse" — postal
// "id emplacement" — legacy_delivery_id (note: only 25% resolves)
function parseMapCsv (text, multi = 'first') {
const rows = parseCsv(text, { skipPreamble: true })
const contacts = []
const seen = new Set()
let skippedNoEmail = 0, skippedNoName = 0
for (const r of rows) {
// Pull emails from either source column
let rawEmails = (r['email au compte'] || r["email à l'adresse"] || '').trim()
if (!rawEmails) { skippedNoEmail++; continue }
const emails = rawEmails.split(EMAIL_SPLIT)
.map(e => normalizeEmail(e))
.filter(e => e.includes('@') && e.split('@')[1]?.includes('.'))
if (!emails.length) { skippedNoEmail++; continue }
const sendEmails = multi === 'split' ? emails
: multi === 'skip' ? (emails.length > 1 ? [] : emails)
: emails.slice(0, 1)
if (!sendEmails.length) continue
const full = (r['nom au compte'] || r["nom à l'adresse"] || '').trim()
if (!full) { skippedNoName++; continue }
const parts = full.split(/\s+/, 2)
const firstname = parts[0] || ''
const lastname = parts.length > 1 ? full.slice(parts[0].length).trim() : ''
const civic_address = titleAddress(r['adresse dans F'] || '')
const postal_code = normalizePostal(r['code postal au compte'] || r["code postal à l'adresse"] || '')
const phone = normalizePhone(r['telephone au compte'] || r["telephone à l'adresse"] || '')
for (const em of sendEmails) {
if (seen.has(em)) continue
seen.add(em)
contacts.push({
firstname, lastname,
email: em,
phone,
civic_address,
postal_code,
})
}
}
return { contacts, skipped: { no_email: skippedNoEmail, no_name: skippedNoName, total_rows: rows.length } }
}
// ── Giftbit CSV parsing ──────────────────────────────────────────────────────
function parseGiftbitCsv (text) {
const rows = parseCsv(text)
if (!rows.length) return []
// Detect URL column from common names
const keys = Object.keys(rows[0])
const urlCandidates = ['gift_url','gift link','gift_link','url','link','shortlink',
'redemption_url','gift','giftbit_link','campaign_link']
let urlCol = null
for (const c of urlCandidates) {
const hit = keys.find(k => k.toLowerCase().replace(/\s+/g, '_') === c)
if (hit) { urlCol = hit; break }
}
// Fallback: any column with http(s)://
if (!urlCol) {
for (const k of keys) {
if ((rows[0][k] || '').match(/^https?:\/\//)) { urlCol = k; break }
}
}
if (!urlCol) return []
return rows.map(r => ({
gift_url: r[urlCol],
giftbit_uuid: r.giftbit_uuid || r.uuid || r.gift_id || '',
gift_value_cents: parseInt(r.gift_value_cents || r.value_cents || r.amount || '0', 10) || 0,
email: normalizeEmail(r.email || ''),
firstname: r.firstname || '',
lastname: r.lastname || '',
internal_id: r.internal_id || '',
}))
}
// ── Customer matching against ERPNext ────────────────────────────────────────
// Strategy: email → phone → civic+postal. First hit wins. Confidence:
// 1.0 = exact email match
// 0.9 = exact phone match
// 0.8 = civic + postal_code on a Service Location (then customer link)
// 0 = unmatched
async function matchCustomer (recipient) {
// 1) Email match on Customer.email_id (most reliable)
if (recipient.email) {
try {
const r = await erp.list('Customer', {
fields: ['name', 'customer_name', 'email_id', 'mobile_no'],
filters: [['email_id', '=', recipient.email]],
limit: 1,
})
if (r && r.length) {
return { customer_id: r[0].name, customer_name: r[0].customer_name,
match_method: 'email', match_confidence: 1.0 }
}
} catch (e) { log('match email error:', e.message) }
}
// 2) Phone match
if (recipient.phone) {
try {
// Try both mobile_no (with various formattings) and the canonical 10-digit form
const r = await erp.list('Customer', {
fields: ['name', 'customer_name', 'mobile_no'],
filters: [['mobile_no', 'like', '%' + recipient.phone.slice(-7) + '%']],
limit: 5,
})
// Filter to exact digit match
const hit = (r || []).find(c => normalizePhone(c.mobile_no) === recipient.phone)
if (hit) {
return { customer_id: hit.name, customer_name: hit.customer_name,
match_method: 'phone', match_confidence: 0.9 }
}
} catch (e) { log('match phone error:', e.message) }
}
// 3) Civic + postal on Service Location → Customer
if (recipient.civic_address && recipient.postal_code) {
try {
const civic = normalizeCivic(recipient.civic_address)
if (civic) {
const [num, streetPrefix] = civic.split('|')
const r = await erp.list('Service Location', {
fields: ['name', 'address_line', 'postal_code', 'customer', 'customer_name'],
filters: [
['postal_code', '=', recipient.postal_code],
['address_line', 'like', num + '%'],
],
limit: 10,
})
const hit = (r || []).find(sl => {
const slCivic = normalizeCivic(sl.address_line)
return slCivic && slCivic.startsWith(num + '|') &&
slCivic.split('|')[1]?.startsWith(streetPrefix.slice(0, 5))
})
if (hit && hit.customer) {
return { customer_id: hit.customer, customer_name: hit.customer_name || hit.customer,
match_method: 'civic', match_confidence: 0.8 }
}
}
} catch (e) { log('match civic error:', e.message) }
}
return { customer_id: null, customer_name: null, match_method: null, match_confidence: 0 }
}
// ── Storage helpers ──────────────────────────────────────────────────────────
function campaignPath (id) {
// Defensive: prevent path traversal — IDs are uuid-like; reject anything else
if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw new Error('invalid campaign id')
return path.join(DATA_DIR, id + '.json')
}
function listCampaigns () {
if (!fs.existsSync(DATA_DIR)) return []
return fs.readdirSync(DATA_DIR)
.filter(f => f.endsWith('.json'))
.map(f => {
try {
const c = JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8'))
return { id: c.id, name: c.name, created_at: c.created_at,
status: c.status, counters: c.counters,
total: (c.recipients || []).length }
} catch { return null }
})
.filter(Boolean)
.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''))
}
function loadCampaign (id) {
const p = campaignPath(id)
if (!fs.existsSync(p)) return null
return JSON.parse(fs.readFileSync(p, 'utf8'))
}
function saveCampaign (campaign) {
// Recompute counters from recipients every save — single source of truth
const c = { ...campaign }
c.counters = (c.recipients || []).reduce((acc, r) => {
acc[r.status] = (acc[r.status] || 0) + 1
return acc
}, { total: (c.recipients || []).length })
fs.writeFileSync(campaignPath(c.id), JSON.stringify(c, null, 2))
return c
}
function newCampaignId () {
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '')
const slug = crypto.randomBytes(3).toString('hex')
return `cmp-${stamp}-${slug}`
}
// ── Template rendering (mirrors send_gift_campaign.js) ───────────────────────
function renderTemplate (tpl, vars) {
// Section blocks first: {{#var}}...{{/var}} kept if var truthy
tpl = tpl.replace(/\{\{\s*#\s*(\w+)\s*\}\}([\s\S]*?)\{\{\s*\/\s*\1\s*\}\}/g,
(_, k, body) => (vars[k] ? body : ''))
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => {
const v = vars[k]
return v == null ? '' : String(v)
})
}
// Default template path — bundled inside the hub at services/targo-hub/templates/.
// Can be overridden per campaign via params.template_path (e.g. for A/B testing).
// Keep the file in sync with scripts/campaigns/templates/gift-email-fr.html
// (the CLI uses that copy for one-off batch sends outside the hub).
const TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates')
const DEFAULT_TEMPLATE = path.join(TEMPLATES_DIR, 'gift-email-fr.html')
// Allow-list templates that can be edited via the API. Anything outside this
// list is rejected (path-traversal defence + intent: only campaign-related
// templates are exposed; contract-residential.html etc. are NOT editable here).
const EDITABLE_TEMPLATES = ['gift-email-fr']
function templatePath (name) {
if (!EDITABLE_TEMPLATES.includes(name)) {
throw new Error(`template not editable: ${name}`)
}
return path.join(TEMPLATES_DIR, name + '.html')
}
function listEditableTemplates () {
return EDITABLE_TEMPLATES.map(name => {
const p = path.join(TEMPLATES_DIR, name + '.html')
let size = 0, modified = null
try {
const stat = fs.statSync(p)
size = stat.size
modified = stat.mtime.toISOString()
} catch {}
return { name, size, modified }
})
}
function readTemplate (tplPath) {
const p = tplPath || DEFAULT_TEMPLATE
if (!fs.existsSync(p)) {
throw new Error(`Template not found: ${p}`)
}
return fs.readFileSync(p, 'utf8')
}
// ── Send-async worker ────────────────────────────────────────────────────────
// Iterates recipients (not excluded, status=pending), sends each via the
// existing email lib, broadcasts each status change over SSE. Throttle is
// configurable. Failures stop only that recipient — the loop continues.
//
// Runs in the background — caller fires this and returns immediately.
const activeWorkers = new Set()
async function sendCampaignAsync (id) {
if (activeWorkers.has(id)) {
log(`campaign ${id} already sending`)
return
}
activeWorkers.add(id)
const topic = `campaign:${id}`
try {
let campaign = loadCampaign(id)
if (!campaign) throw new Error(`campaign ${id} not found`)
campaign.status = 'sending'
campaign.send_started_at = new Date().toISOString()
campaign = saveCampaign(campaign)
sse.broadcast(topic, 'campaign-status', { id, status: 'sending' })
const p = campaign.params || {}
const tplText = readTemplate(p.template_path)
const throttle = parseInt(p.throttle_ms || 600, 10)
for (let i = 0; i < campaign.recipients.length; i++) {
const r = campaign.recipients[i]
if (r.excluded || r.status !== 'pending') continue
// Mark queued so the UI shows movement immediately
r.status = 'queued'
saveCampaign(campaign)
sse.broadcast(topic, 'recipient-update', { i, recipient: r })
const vars = {
firstname: r.firstname || 'cher client',
lastname: r.lastname || '',
email: r.email,
description: r.civic_address || '',
gift_url: r.gift_url,
amount: p.amount || '50 $',
expiry: p.expiry || '',
commitment_months: p.commitment_months || '3',
}
const html = renderTemplate(tplText, vars)
const toName = `${r.firstname || ''} ${r.lastname || ''}`.trim()
const to = toName ? `"${toName}" <${r.email}>` : r.email
// Set Mailjet CustomID = campaign_id:recipient_index so the Event API
// webhook events can be matched back to a specific recipient row.
// Mailjet echoes this value in every event under the CustomID field;
// SMTP messageId alone is not a reliable join key (different ID space).
const customId = `${id}:${i}`
r.mailjet_custom_id = customId
// email.sendEmail returns the nodemailer info object on success
// (truthy, with .messageId), or `false` on failure (error logged in
// lib/email.js). It doesn't throw. We treat falsy = failed.
let sendRes
try {
sendRes = await email.sendEmail({
to,
subject: p.subject || 'Un cadeau pour vous, de la part de TARGO',
html,
from: p.from || cfg.MAIL_FROM,
headers: { 'X-MJ-CustomID': customId },
})
} catch (e) {
sendRes = false
r.error = String(e.message || e).slice(0, 500)
}
if (sendRes && sendRes.messageId !== undefined) {
r.mailjet_uuid = sendRes.messageId || null // SMTP Message-ID for reference
r.status = 'sent'
r.sent_at = new Date().toISOString()
r.error = null
} else {
r.status = 'failed'
if (!r.error) r.error = 'SMTP send returned false (see hub logs)'
log(`campaign ${id} recipient ${i} failed:`, r.error)
}
saveCampaign(campaign)
sse.broadcast(topic, 'recipient-update', { i, recipient: r })
if (throttle > 0) await new Promise(rs => setTimeout(rs, throttle))
}
campaign = loadCampaign(id)
campaign.status = 'completed'
campaign.send_completed_at = new Date().toISOString()
campaign = saveCampaign(campaign)
sse.broadcast(topic, 'campaign-done', { id, counters: campaign.counters })
log(`campaign ${id} done — ${campaign.counters.sent || 0} sent, ${campaign.counters.failed || 0} failed`)
} catch (e) {
log(`campaign ${id} worker failed:`, e.message)
try {
const c = loadCampaign(id)
if (c) {
c.status = 'failed'
c.error = String(e.message || e)
saveCampaign(c)
sse.broadcast(topic, 'campaign-done', { id, error: c.error })
}
} catch {}
} finally {
activeWorkers.delete(id)
}
}
// ── Mailjet webhook receiver ─────────────────────────────────────────────────
// Mailjet's Event API posts a JSON array of events:
// [{"event": "sent"|"open"|"click"|"bounce"|"blocked"|"spam"|"unsub",
// "MessageID": 1234, "CustomID": "<our reference>",
// "time": 1234567890, "email": "...", "Payload": "..."}, ...]
// We match by mailjet_uuid (== MessageID) and update status across all
// campaigns. We don't know which campaign a given event belongs to without
// scanning, but for the volumes we're dealing with (a few campaigns × ~200
// recipients each) scanning all open JSON files per webhook is fine.
function mailjetEventToStatus (event) {
switch ((event || '').toLowerCase()) {
case 'sent': return null // already 'sent' from SMTP — informational
case 'open': return 'opened'
case 'click': return 'clicked'
case 'bounce':
case 'hardbounce':
case 'softbounce': return 'bounced'
case 'blocked':
case 'spam':
case 'unsub': return 'failed'
default: return null
}
}
function applyWebhookEvent (ev) {
const newStatus = mailjetEventToStatus(ev.event)
if (!newStatus) return false
// Primary join key: CustomID = "<campaign-id>:<recipient-index>" which we
// injected on send via X-MJ-CustomID. Falling back to MessageID for events
// that might predate the CustomID rollout.
const customId = String(ev.CustomID || ev.custom_id || '')
const msgId = String(ev.MessageID || ev.message_id || '')
// Fast path: parse CustomID to skip the campaign scan entirely
if (customId && customId.includes(':')) {
const [campId, idxStr] = customId.split(':')
const idx = parseInt(idxStr, 10)
const c = loadCampaign(campId)
if (c && c.recipients && c.recipients[idx]) {
const r = c.recipients[idx]
r.status = newStatus
if (newStatus === 'opened') r.opened_at = new Date((ev.time || 0) * 1000).toISOString()
if (newStatus === 'clicked') r.clicked_at = new Date((ev.time || 0) * 1000).toISOString()
if (newStatus === 'bounced' || newStatus === 'failed') {
r.error = ev.error || ev.error_related_to || ev.event
}
saveCampaign(c)
sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i: idx, recipient: r })
return true
}
}
// Fallback: scan all campaigns for a recipient matching msgId (slower)
if (!msgId) return false
for (const meta of listCampaigns()) {
const c = loadCampaign(meta.id)
if (!c) continue
for (let i = 0; i < (c.recipients || []).length; i++) {
const r = c.recipients[i]
if (String(r.mailjet_uuid) !== msgId) continue
r.status = newStatus
if (newStatus === 'opened') r.opened_at = new Date((ev.time || 0) * 1000).toISOString()
if (newStatus === 'clicked') r.clicked_at = new Date((ev.time || 0) * 1000).toISOString()
if (newStatus === 'bounced' || newStatus === 'failed') {
r.error = ev.error || ev.error_related_to || ev.event
}
saveCampaign(c)
sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i, recipient: r })
return true
}
}
return false
}
// ── HTTP routing ─────────────────────────────────────────────────────────────
async function handle (req, res, method, path) {
// POST /campaigns/parse — preview matched send list (no save)
if (path === '/campaigns/parse' && method === 'POST') {
const body = await parseBody(req)
const { map_csv, giftbit_csv, multi } = body || {}
if (!map_csv || !giftbit_csv) {
return json(res, 400, { error: 'map_csv and giftbit_csv required' })
}
const { contacts, skipped } = parseMapCsv(map_csv, multi || 'first')
const gifts = parseGiftbitCsv(giftbit_csv)
// Match contacts ↔ gifts (by row order, fallback to email)
const recipients = []
const n = Math.min(contacts.length, gifts.length)
for (let i = 0; i < n; i++) {
const c = contacts[i]; const g = gifts[i]
// If Giftbit echoed back our email, prefer that match for robustness
const giftByEmail = gifts.find(gg => normalizeEmail(gg.email) === c.email)
const gift = giftByEmail || g
const match = await matchCustomer(c)
recipients.push({
...c,
gift_url: gift.gift_url,
giftbit_uuid: gift.giftbit_uuid,
gift_value_cents: gift.gift_value_cents,
...match,
status: 'pending',
excluded: false,
})
}
return json(res, 200, {
recipients,
leftover_gifts: gifts.length - n,
leftover_contacts: contacts.length - n,
skipped,
})
}
// POST /campaigns — create from a parsed send list
if (path === '/campaigns' && method === 'POST') {
const body = await parseBody(req)
const { name, params, recipients } = body || {}
if (!Array.isArray(recipients) || !recipients.length) {
return json(res, 400, { error: 'recipients[] required' })
}
const id = newCampaignId()
const campaign = {
id,
name: name || `Campagne ${id}`,
created_at: new Date().toISOString(),
status: 'draft',
params: params || {},
recipients: recipients.map(r => ({ ...r, status: r.status || 'pending' })),
}
const saved = saveCampaign(campaign)
return json(res, 200, saved)
}
// GET /campaigns — list summaries
if (path === '/campaigns' && method === 'GET') {
return json(res, 200, { campaigns: listCampaigns() })
}
// ── 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') {
return json(res, 200, { templates: listEditableTemplates() })
}
// GET /campaigns/templates/:name — return current HTML content
const tplGet = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)$/)
if (tplGet && method === 'GET') {
try {
const html = fs.readFileSync(templatePath(tplGet[1]), 'utf8')
return json(res, 200, { name: tplGet[1], html })
} catch (e) {
return json(res, 404, { error: 'template not found', detail: e.message })
}
}
// PUT /campaigns/templates/:name — save new HTML
// Keeps the previous version as <name>.bak-<ts>.html so a bad edit can
// be rolled back without git access.
if (tplGet && method === 'PUT') {
const body = await parseBody(req)
if (typeof body.html !== 'string' || !body.html) {
return json(res, 400, { error: 'html string required' })
}
try {
const p = templatePath(tplGet[1])
// Backup current version (if any) — best effort, don't fail save on backup error
try {
if (fs.existsSync(p)) {
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
fs.copyFileSync(p, p.replace(/\.html$/, `.bak-${ts}.html`))
}
} catch (e) { log('template backup failed:', e.message) }
fs.writeFileSync(p, body.html, 'utf8')
log(`template ${tplGet[1]} updated by ops (${body.html.length} bytes)`)
return json(res, 200, { name: tplGet[1], saved: true, size: body.html.length })
} catch (e) {
return json(res, 400, { error: e.message })
}
}
// POST /campaigns/templates/:name/preview — render with sample data
// Useful for the editor's "Preview" pane to see what {{vars}} resolve to.
const tplPreview = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)\/preview$/)
if (tplPreview && method === 'POST') {
const body = await parseBody(req)
const html = body.html || fs.readFileSync(templatePath(tplPreview[1]), 'utf8')
const vars = {
firstname: 'Louis', lastname: 'Paul', email: 'louis@targo.ca',
description: '123 Rue de Test', gift_url: 'http://gtbt.co/PREVIEW',
amount: '60 $', expiry: '31 décembre 2026', commitment_months: '3',
...(body.vars || {}),
}
return json(res, 200, { rendered: renderTemplate(html, vars) })
}
// POST /campaigns/webhook — Mailjet Event API receiver
// Mailjet sends an array of events; we process all of them.
if (path === '/campaigns/webhook' && method === 'POST') {
const body = await parseBody(req)
const events = Array.isArray(body) ? body : [body]
let applied = 0
for (const ev of events) {
if (applyWebhookEvent(ev)) applied++
}
log(`mailjet webhook: ${events.length} events, ${applied} applied`)
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' })
}
module.exports = {
handle,
// Exposed for testing
parseCsv, parseMapCsv, parseGiftbitCsv,
matchCustomer, normalizeCivic, normalizePhone, normalizePostal,
renderTemplate,
}