diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js new file mode 100644 index 0000000..fe16e09 --- /dev/null +++ b/services/targo-hub/lib/campaigns.js @@ -0,0 +1,751 @@ +'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/.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:' — 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": "", +// "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 = ":" 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() }) + } + + // 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) ───────────────── + + // 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 .bak-.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 }) + } + + return json(res, 404, { error: 'campaigns endpoint not found' }) +} + +module.exports = { + handle, + // Exposed for testing + parseCsv, parseMapCsv, parseGiftbitCsv, + matchCustomer, normalizeCivic, normalizePhone, normalizePostal, + renderTemplate, +} diff --git a/services/targo-hub/lib/email.js b/services/targo-hub/lib/email.js index b5b0f80..4056725 100644 --- a/services/targo-hub/lib/email.js +++ b/services/targo-hub/lib/email.js @@ -47,11 +47,15 @@ async function sendEmail (opts) { } const mailOpts = { - from: cfg.MAIL_FROM, + from: opts.from || cfg.MAIL_FROM, to: opts.to, subject: opts.subject, html: opts.html, attachments: [], + // Custom headers (e.g. X-MJ-CustomID for Mailjet Event API webhook + // correlation — Mailjet echoes the CustomID back in every event so + // we can match webhook events to the originating recipient). + headers: opts.headers || {}, } if (opts.pdfBuffer && opts.pdfFilename) { @@ -65,9 +69,16 @@ async function sendEmail (opts) { try { const info = await transport.sendMail(mailOpts) log(`Email sent to ${opts.to}: ${info.messageId || 'OK'}`) - return true + // Return the info object (always truthy) so callers can capture + // info.messageId for tracking. Legacy `if (await sendEmail(...))` + // callers continue to work because the object is truthy. + return info || { messageId: null } } catch (e) { log(`Email send failed to ${opts.to}: ${e.message}`) + // Legacy contract: return false on failure. New callers that need the + // error string should check `Promise.allSettled` style or wrap in try + // (we don't throw here to preserve existing `if (await sendEmail(...))` + // call sites). The error is logged above. return false } } diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js index 649de5d..145209b 100644 --- a/services/targo-hub/server.js +++ b/services/targo-hub/server.js @@ -119,6 +119,7 @@ const server = http.createServer(async (req, res) => { if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path) if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').handle(req, res, method, path) if (path.startsWith('/reports')) return require('./lib/reports').handle(req, res, method, path, url) + if (path.startsWith('/campaigns')) return require('./lib/campaigns').handle(req, res, method, path) if (path.startsWith('/contract')) return require('./lib/contracts').handle(req, res, method, path) if (path.startsWith('/payments') || path === '/webhook/stripe') return require('./lib/payments').handle(req, res, method, path, url) if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res) diff --git a/services/targo-hub/templates/gift-email-fr.html b/services/targo-hub/templates/gift-email-fr.html new file mode 100644 index 0000000..9394183 --- /dev/null +++ b/services/targo-hub/templates/gift-email-fr.html @@ -0,0 +1,331 @@ + + + + + +Une offre exclusive de TARGO + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{#expiry}} + + + + + + {{/expiry}} + + + + + + + + + +
+ TARGO +
+

Bonjour {{firstname}},

+

+ Tu choisis local, on veut te remercier. +

+

+ Avec l'arrivée de l'été, voici ton + offre exclusive pour un temps limité : +

+
+
+
+ Carte-cadeau numérique +
+
+ 🎁 {{amount}} chez des centaines de marques +
+
+
+ + + + + +
+
+
+ Envoi +
+
+ ⚡ Instantané à l'activation +
+
+
+
+
+ Condition +
+
+ 🤝 Rester encore {{commitment_months}} mois ou + +
+
+
+
+ + ✅ Option 1 + +
+ + + + + +
+
+ 🎁  {{amount}} +
+
+ Activer ma carte-cadeau +
+
+ Choisir ma carte sur Giftbit → +
+
+
+
+
+ 🪂 En cas de départ avant {{commitment_months}} mois, le prorata du montant est remboursable. +
+
+ + ⏭️ Option 2 + +
+
+ Ne rien faire. Ton abonnement mensuel se poursuit normalement, + sans engagement ni carte-cadeau. +
+
+
+ ⏰ Cette offre expire le {{expiry}}. +
+
+
+ 🤝 Merci de faire rouler l'économie de notre région avec nous ! +
+
+ L'équipe TARGO +
+
+ + + + + + + + + +
+
+ Quelques exemples de choix pour votre carte cadeau : +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ Amazon +
+
+ + +
+ IGA +
+
+ + +
+ Tim Hortons +
+
+ + +
+ $1 Plus +
+
+ + +
+ Pizza Pizza +
+
+ + +
+ Home Depot +
+
+ + +
+ Best Buy +
+
+ + +
+ Walmart +
+
+ + +
+ Petro-Canada +
+
+ + +
+ Esso +
+
+ + +
+ Home Hardware +
+
+ + +
+ Sobeys +
+
+
+ + + + + + + + + +
+
+ Tu reçois ce courriel parce que tu es client(e) TARGO à + {{description}}.
+ Une question ? Écris-nous à + facturation@targointernet.com + ou appelle au 514 242-1500. +
+
+
+ TARGO + — Internet fibre optique au Québec — service Gigafibre
+ targointernet.com + · + gigafibre.ca +
+
+ +
+ + +