Step 2 of the new-campaign wizard previously dropped unpaired contacts silently (Math.min(contacts, gifts) iteration) — if you uploaded 5 contacts and 3 gift links, you got 3 recipients in the table with no visible signal that 2 contacts were left out. Step 1 only showed "contacts skipped: N" in a small banner, easy to miss. Surface the imbalance explicitly so the user can decide before sending: Backend (POST /campaigns/parse): - Return unpaired_contacts[] and unused_gifts[] arrays (with row_index for source-CSV cross-reference), in addition to the existing recipients[]. Old leftover_gifts / leftover_contacts counters kept for backward compat. UI (CampaignNewPage Step 2): - New columns in the recipients table: • # (row index from the source CSVs) • Lien-cadeau (truncated shortlink, clickable to verify) These let the user eyeball the contact↔link pairing line by line. - New counter strip: Paires / À envoyer / Client lié / Sans client / Sans lien / Liens surplus - "Sans lien" and "Liens surplus" counters appear only when relevant. - Explicit warning banner explaining what unpaired/unused means (acquire more links and re-upload, or proceed knowing N won't get). - Expansion panel listing each unpaired contact with their row_index + details, so the user can verify which specific contacts will be excluded before approving. - Expansion panel listing each unused gift URL (extra capacity). - "Approuver" button now shows the exact send count: "Approuver — N à envoyer". Disabled when 0. Step 3 recap also reflects sendableCount. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
818 lines
33 KiB
JavaScript
818 lines
33 KiB
JavaScript
'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 ──────────────────────────────────────────────────────
|
||
// Two supported formats:
|
||
//
|
||
// 1. "Link Order" export (headerless, one URL per line):
|
||
// http://gft.link/4kpZMApLK4B
|
||
// http://gft.link/Dn2cb27xYJ8
|
||
//
|
||
// This is what Giftbit ships when you pre-buy N gift links upfront without
|
||
// targeting specific recipients. Each line = one redeemable shortlink. The
|
||
// recipient mapping is done entirely on our side (Map CSV ↔ link by row).
|
||
//
|
||
// 2. "Campaign" export (with header row, multiple columns):
|
||
// firstname,lastname,email,gift_url,giftbit_uuid,gift_value_cents,internal_id
|
||
// Alice,Tremblay,alice@x.com,https://...,uuid-001,5000,ACC-1
|
||
//
|
||
// This is what create_giftbit_campaign.js produces (and what Giftbit
|
||
// sends when you use their Campaign API with delivery_type=SHORTLINK).
|
||
function parseGiftbitCsv (text) {
|
||
const cleaned = text.replace(/^/, '').trim()
|
||
if (!cleaned) return []
|
||
|
||
// Detect headerless one-URL-per-line format: first non-empty line starts
|
||
// with http:// or https:// and has no comma/pipe/tab separators.
|
||
const firstLine = cleaned.split(/\r?\n/, 1)[0].trim()
|
||
if (/^https?:\/\/\S+$/.test(firstLine) && !firstLine.includes(',') && !firstLine.includes('\t') && !firstLine.includes('|')) {
|
||
return cleaned.split(/\r?\n/)
|
||
.map(l => l.trim())
|
||
.filter(l => /^https?:\/\//.test(l))
|
||
.map(url => ({
|
||
gift_url: url,
|
||
giftbit_uuid: '',
|
||
gift_value_cents: 0,
|
||
email: '',
|
||
firstname: '',
|
||
lastname: '',
|
||
internal_id: '',
|
||
}))
|
||
}
|
||
|
||
// Otherwise treat as CSV with header row
|
||
const rows = parseCsv(text)
|
||
if (!rows.length) return []
|
||
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 }
|
||
}
|
||
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)
|
||
// Returns:
|
||
// - recipients[]: contacts paired with a gift_url (ready to send)
|
||
// - unpaired_contacts[]: contacts with no matching gift (won't be sent —
|
||
// these are surfaced so the user can decide whether to acquire more
|
||
// gift links and re-upload, or proceed anyway)
|
||
// - unused_gifts[]: gift URLs with no matching contact (lost capacity —
|
||
// surfaced so the user knows the imbalance)
|
||
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 when Giftbit
|
||
// echoed back our recipient email in their export)
|
||
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]
|
||
const giftByEmail = gifts.find(gg => normalizeEmail(gg.email) === c.email)
|
||
const gift = giftByEmail || g
|
||
const match = await matchCustomer(c)
|
||
recipients.push({
|
||
row_index: i + 1,
|
||
...c,
|
||
gift_url: gift.gift_url,
|
||
giftbit_uuid: gift.giftbit_uuid,
|
||
gift_value_cents: gift.gift_value_cents,
|
||
...match,
|
||
status: 'pending',
|
||
excluded: false,
|
||
})
|
||
}
|
||
|
||
// Capture the imbalance: leftover contacts have no gift, leftover gifts
|
||
// have no contact. Returning them as arrays (not just counts) lets the
|
||
// UI render them so the user makes an informed decision.
|
||
const unpaired_contacts = contacts.slice(n).map((c, idx) => ({
|
||
row_index: n + idx + 1,
|
||
...c,
|
||
}))
|
||
const unused_gifts = gifts.slice(n).map((g, idx) => ({
|
||
row_index: n + idx + 1,
|
||
...g,
|
||
}))
|
||
|
||
return json(res, 200, {
|
||
recipients,
|
||
unpaired_contacts,
|
||
unused_gifts,
|
||
// Kept for backward compat with any older callers
|
||
leftover_gifts: unused_gifts.length,
|
||
leftover_contacts: unpaired_contacts.length,
|
||
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,
|
||
}
|