Manual workaround for redemption status until /gifts/{uuid} polling
ships (task #25). The trailing path segment of the Giftbit shortlink
is the lookup key for Giftbit's admin search:
http://gft.link/4kpZMApLK4B
→ https://app.giftbit.com/app/rewards?search=4kpZMApLK4B
Surfaced in three places:
- Inventory page row: 🔗 button next to the copy-URL action
- Campaign detail page recipient table: same button next to the
Giftbit shortlink
- CSV report: new giftbit_admin_url column for bulk audits in Excel
(one click per row, no manual concat)
Defensive: only renders if the trailing segment is ≥4 chars (avoids
producing useless searches on malformed/test URLs).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1993 lines
88 KiB
JavaScript
1993 lines
88 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')
|
||
// MJML v5 is async — compile MJML source to email-safe HTML server-side at
|
||
// save time so the send-worker only ever reads pre-compiled HTML.
|
||
const mjml2html = require('mjml')
|
||
|
||
// AI service (Gemini Flash) — used by the template translation endpoint to
|
||
// convert email content between FR ↔ EN while preserving HTML structure.
|
||
// Lazy require because lib/ai.js requires AI_API_KEY which may not be set
|
||
// in dev environments — we only fail when the endpoint is actually called.
|
||
let aiLib = null
|
||
function getAi () {
|
||
if (!aiLib) aiLib = require('./ai')
|
||
return aiLib
|
||
}
|
||
|
||
const DATA_DIR = path.join(__dirname, '..', 'data', 'campaigns')
|
||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
|
||
|
||
// User-uploaded image assets (logos, photos, illustrations). Self-hosted so
|
||
// we're not dependent on Mailjet's CDN for new images added post-template-
|
||
// creation. Existing Mailjet-hosted brand logos in gift-email-fr.html stay
|
||
// where they are — this is for ADDITIONAL assets the user wants to drop in
|
||
// via the GrapesJS editor.
|
||
//
|
||
// Naming: <sha256-of-content>.<ext>. Content-addressable = automatic dedup
|
||
// (same image uploaded twice = same URL, no duplicate storage) + immutable
|
||
// cache (URL never changes content, safe to Cache-Control: immutable).
|
||
const UPLOADS_DIR = path.join(__dirname, '..', 'uploads')
|
||
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true })
|
||
|
||
const ALLOWED_IMAGE_TYPES = {
|
||
'image/png': 'png',
|
||
'image/jpeg': 'jpg',
|
||
'image/gif': 'gif',
|
||
'image/webp': 'webp',
|
||
'image/svg+xml': 'svg',
|
||
}
|
||
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024 // 5 MB raw image
|
||
|
||
// Decode a `data:image/png;base64,iVBOR...` URL into { mime, ext, buffer }.
|
||
// Returns null on invalid input.
|
||
function decodeDataUrl (dataUrl) {
|
||
if (typeof dataUrl !== 'string') return null
|
||
const m = dataUrl.match(/^data:([^;,]+)(;base64)?,(.*)$/)
|
||
if (!m) return null
|
||
const mime = m[1].toLowerCase()
|
||
const isBase64 = !!m[2]
|
||
if (!ALLOWED_IMAGE_TYPES[mime]) return null
|
||
let buffer
|
||
try {
|
||
buffer = isBase64
|
||
? Buffer.from(m[3], 'base64')
|
||
: Buffer.from(decodeURIComponent(m[3]), 'binary')
|
||
} catch { return null }
|
||
if (buffer.length === 0) return null
|
||
if (buffer.length > MAX_UPLOAD_BYTES) return { error: 'too_large', size: buffer.length }
|
||
return { mime, ext: ALLOWED_IMAGE_TYPES[mime], buffer }
|
||
}
|
||
|
||
function uploadPath (filename) {
|
||
// Only accept hash-named files: 64 hex chars + .ext (no path separator,
|
||
// no '..', no leading dot). Anything else = rejected (path traversal).
|
||
if (!/^[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(filename)) {
|
||
throw new Error('invalid asset filename')
|
||
}
|
||
return path.join(UPLOADS_DIR, filename)
|
||
}
|
||
|
||
// Write an uploaded buffer to disk (dedup: skip if same content already
|
||
// stored). Returns the persisted filename. Module-level (not inside handle)
|
||
// because `path` inside handle() is shadowed by the URL parameter.
|
||
function persistUpload (buffer, ext) {
|
||
const hash = crypto.createHash('sha256').update(buffer).digest('hex')
|
||
const filename = `${hash}.${ext}`
|
||
const target = path.join(UPLOADS_DIR, filename)
|
||
if (!fs.existsSync(target)) fs.writeFileSync(target, buffer)
|
||
return filename
|
||
}
|
||
|
||
function readUpload (filename) {
|
||
if (!/^[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(filename)) return null
|
||
const p = path.join(UPLOADS_DIR, filename)
|
||
if (!fs.existsSync(p)) return null
|
||
const ext = path.extname(p).slice(1)
|
||
const mime = Object.entries(ALLOWED_IMAGE_TYPES).find(([, e]) => e === ext)?.[0] || 'application/octet-stream'
|
||
return { buffer: fs.readFileSync(p), mime }
|
||
}
|
||
|
||
function deleteUpload (filename) {
|
||
if (!/^[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(filename)) return false
|
||
const p = path.join(UPLOADS_DIR, filename)
|
||
if (!fs.existsSync(p)) return false
|
||
fs.unlinkSync(p)
|
||
return true
|
||
}
|
||
|
||
function uploadUrl (filename, req) {
|
||
// Build the absolute URL the browser will use. Falls back to a relative
|
||
// path if Host header is missing (shouldn't happen on prod via Traefik).
|
||
const proto = (req.headers['x-forwarded-proto'] || 'https')
|
||
const host = req.headers['host'] || 'msg.gigafibre.ca'
|
||
return `${proto}://${host}/campaigns/assets/${filename}`
|
||
}
|
||
|
||
function listUploads (req) {
|
||
if (!fs.existsSync(UPLOADS_DIR)) return []
|
||
return fs.readdirSync(UPLOADS_DIR)
|
||
.filter(f => /^[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(f))
|
||
.map(f => {
|
||
const st = fs.statSync(path.join(UPLOADS_DIR, f))
|
||
return {
|
||
filename: f,
|
||
url: uploadUrl(f, req),
|
||
size: st.size,
|
||
modified: st.mtime.toISOString(),
|
||
content_type: Object.entries(ALLOWED_IMAGE_TYPES).find(([, e]) => f.endsWith('.' + e))?.[0] || 'application/octet-stream',
|
||
}
|
||
})
|
||
.sort((a, b) => (b.modified || '').localeCompare(a.modified || ''))
|
||
}
|
||
|
||
// ── 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*/
|
||
|
||
// ── French Québécois first-name accent restoration ──────────────────────────
|
||
// Top ~100 names from MIQ Service Canada Québec baby names + manual additions
|
||
// for older generations (legacy customer base skews 40+). Lookup is on the
|
||
// LOWERCASE no-accent form → returns the properly accented version.
|
||
// Missing from this list: rare names, English names that don't need fixing,
|
||
// already-correctly-accented names (handled by Title Case alone).
|
||
const FR_NAME_FIXES = {
|
||
// É (acute)
|
||
'andre': 'André', 'andree': 'Andrée', 'aimee': 'Aimée',
|
||
'amelie': 'Amélie', 'angele': 'Angèle', 'audrey': 'Audrey',
|
||
'beatrice': 'Béatrice', 'benedict': 'Bénédict',
|
||
'cecile': 'Cécile', 'cedric': 'Cédric', 'celine': 'Céline', 'chloe': 'Chloé',
|
||
'clemence': 'Clémence', 'clement': 'Clément',
|
||
'desire': 'Désiré', 'desiree': 'Désirée',
|
||
'edith': 'Édith', 'edouard': 'Édouard', 'eliane': 'Éliane',
|
||
'elie': 'Élie', 'elisa': 'Élisa', 'elise': 'Élise',
|
||
'eloi': 'Éloi', 'emile': 'Émile', 'emilie': 'Émilie',
|
||
'emmanuel': 'Emmanuel', 'eric': 'Éric', 'ethan': 'Éthan', 'eugene': 'Eugène',
|
||
'felix': 'Félix', 'fleur': 'Fleur', 'francois': 'François', 'francoise': 'Françoise',
|
||
'frederic': 'Frédéric', 'frederique': 'Frédérique',
|
||
'gabrielle': 'Gabrielle', 'gaetan': 'Gaétan', 'gaetane': 'Gaétane',
|
||
'genevieve': 'Geneviève', 'gerald': 'Gérald', 'gerard': 'Gérard',
|
||
'helena': 'Helena', 'helene': 'Hélène', 'herve': 'Hervé',
|
||
'irene': 'Irène', 'jeremie': 'Jérémie', 'jeremy': 'Jérémie',
|
||
'jerome': 'Jérôme', 'jose': 'José', 'josee': 'Josée',
|
||
'leandre': 'Léandre', 'leon': 'Léon', 'leo': 'Léo', 'leonard': 'Léonard',
|
||
'magali': 'Magali', 'medard': 'Médard',
|
||
'melanie': 'Mélanie', 'michele': 'Michèle',
|
||
'noemie': 'Noémie', 'noel': 'Noël', 'noella': 'Noëlla',
|
||
'pamela': 'Paméla', 'pierre': 'Pierre',
|
||
'raphael': 'Raphaël', 'raphaele': 'Raphaëlle', 'regina': 'Régina',
|
||
'regine': 'Régine', 'rejean': 'Réjean', 'rejeanne': 'Réjeanne',
|
||
'remi': 'Rémi', 'rene': 'René', 'renee': 'Renée',
|
||
'salome': 'Salomé', 'sebastien': 'Sébastien', 'severin': 'Séverin',
|
||
'severine': 'Séverine', 'solange': 'Solange',
|
||
'stephane': 'Stéphane', 'stephanie': 'Stéphanie',
|
||
'theo': 'Théo', 'theodore': 'Théodore', 'therese': 'Thérèse',
|
||
'valerie': 'Valérie', 'veronique': 'Véronique', 'zoe': 'Zoé',
|
||
}
|
||
|
||
// Parts known to form compound first names in QC (Marie-André, Jean-Philippe,
|
||
// etc.). When two parts appear concatenated with no separator (e.g. "Marcandre"
|
||
// or "Mariejose"), we split + hyphenate them. Ordered roughly by frequency so
|
||
// the longest match wins on lookups.
|
||
const COMPOUND_PARTS = [
|
||
'jean', 'marie', 'anne', 'pierre', 'paul', 'louis', 'claude', 'marc',
|
||
'andre', 'philippe', 'francois', 'francoise', 'jose', 'josee',
|
||
'luc', 'olivier', 'antoine', 'sebastien', 'michel', 'francois',
|
||
'christian', 'henri', 'denis', 'rene', 'roger',
|
||
]
|
||
|
||
function splitCompoundName (lower) {
|
||
for (const a of COMPOUND_PARTS) {
|
||
if (!lower.startsWith(a)) continue
|
||
const rest = lower.slice(a.length)
|
||
if (!rest) continue
|
||
if (COMPOUND_PARTS.includes(rest)) {
|
||
return [a, rest]
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
// Title-case a single token. Handles apostrophes (O'Brien, L'Heureux) and
|
||
// hyphens (Marie-André). Returns input unchanged if empty.
|
||
function titleCaseToken (tok) {
|
||
if (!tok) return tok
|
||
return tok.replace(/(^|[\s\-'])([\p{L}])/gu, (_, sep, ch) => sep + ch.toUpperCase())
|
||
.replace(/(?<=[\p{L}])([\p{L}]+)/gu, m => m.toLowerCase())
|
||
}
|
||
|
||
// Clean a person name: trim, Title Case, accent-restore from dictionary,
|
||
// split known compound concatenations. Returns the cleaned name.
|
||
function cleanName (raw) {
|
||
if (!raw) return ''
|
||
let s = raw.trim()
|
||
if (!s) return ''
|
||
// First pass: Title Case (so 'MARC' → 'Marc', 'marc' → 'Marc')
|
||
s = s.split(/\s+/).map(titleCaseToken).join(' ')
|
||
|
||
// Apply dictionary fixes to each space-separated word
|
||
s = s.split(' ').map(word => {
|
||
// For hyphenated compounds, try each part separately
|
||
if (word.includes('-')) {
|
||
return word.split('-').map(p => {
|
||
const fix = FR_NAME_FIXES[p.toLowerCase()]
|
||
return fix || titleCaseToken(p)
|
||
}).join('-')
|
||
}
|
||
const lower = word.toLowerCase()
|
||
if (FR_NAME_FIXES[lower]) return FR_NAME_FIXES[lower]
|
||
// Try compound split: "Marcandre" → "Marc-Andre" → "Marc-André"
|
||
const parts = splitCompoundName(lower)
|
||
if (parts) {
|
||
return parts.map(p => FR_NAME_FIXES[p] || titleCaseToken(p)).join('-')
|
||
}
|
||
return word
|
||
}).join(' ')
|
||
|
||
return s
|
||
}
|
||
|
||
// Heuristics to flag names the cleaner may not have caught — surface to the
|
||
// user in the preview UI so they can manually verify / edit before sending.
|
||
// Returns null if the name looks fine, or a short FR string explaining why.
|
||
function nameWarning (name) {
|
||
if (!name) return null
|
||
const trimmed = name.trim()
|
||
if (!trimmed) return null
|
||
if (/\d/.test(trimmed)) return 'contient un chiffre'
|
||
if (trimmed.length === 1) return 'une seule lettre'
|
||
if (trimmed.length > 25) return 'trop long, peut-être plusieurs noms collés'
|
||
// After cleanName, common-name lookups should have hit. If we still see a
|
||
// name in the unaccented form, it's likely an uncommon name OR a typo.
|
||
// We can't tell which, so just inform the user.
|
||
const SUSPECT_PATTERNS = [
|
||
// Common unaccented forms that the dict should have caught — if they
|
||
// survive, the name is unusual and worth a glance
|
||
/^(ana|ari|cleo|elo|jul|leo|maw|oce|pen|sof|the|val)/i,
|
||
]
|
||
// Likely two names mashed (no separator, length 13-25, lowercase or with
|
||
// unexpected capitalisation pattern like 'Marcandre')
|
||
if (/^[\p{Lu}][\p{Ll}]+[\p{Ll}]{8,}$/u.test(trimmed) && !/[\s\-']/.test(trimmed)) {
|
||
return 'peut-être deux prénoms collés (vérifier)'
|
||
}
|
||
return null
|
||
}
|
||
|
||
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 firstnameRaw = parts[0] || ''
|
||
const lastnameRaw = parts.length > 1 ? full.slice(parts[0].length).trim() : ''
|
||
|
||
// Auto-clean: Title Case, accent restoration from QC dictionary, compound
|
||
// name detection. The user sees these cleaned versions in Step 2 and can
|
||
// edit inline. Original raw values are kept on the recipient (firstname_raw
|
||
// + lastname_raw) so we know whether the auto-cleaner touched anything.
|
||
const firstname = cleanName(firstnameRaw)
|
||
const lastname = cleanName(lastnameRaw)
|
||
const name_warnings = {
|
||
firstname: nameWarning(firstname),
|
||
lastname: nameWarning(lastname),
|
||
}
|
||
const cleaned_changed = (firstname !== firstnameRaw || lastname !== lastnameRaw)
|
||
|
||
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,
|
||
firstname_raw: firstnameRaw, lastname_raw: lastnameRaw,
|
||
cleaned_changed,
|
||
name_warnings,
|
||
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', 'language'],
|
||
filters: [['email_id', '=', recipient.email]],
|
||
limit: 1,
|
||
})
|
||
if (r && r.length) {
|
||
return { customer_id: r[0].name, customer_name: r[0].customer_name,
|
||
language: r[0].language || 'fr',
|
||
match_method: 'email', match_confidence: 1.0 }
|
||
}
|
||
} catch (e) { log('match email error:', e.message) }
|
||
}
|
||
|
||
// 2) Phone match
|
||
if (recipient.phone) {
|
||
try {
|
||
const r = await erp.list('Customer', {
|
||
fields: ['name', 'customer_name', 'mobile_no', 'language'],
|
||
filters: [['mobile_no', 'like', '%' + recipient.phone.slice(-7) + '%']],
|
||
limit: 5,
|
||
})
|
||
const hit = (r || []).find(c => normalizePhone(c.mobile_no) === recipient.phone)
|
||
if (hit) {
|
||
return { customer_id: hit.name, customer_name: hit.customer_name,
|
||
language: hit.language || 'fr',
|
||
match_method: 'phone', match_confidence: 0.9 }
|
||
}
|
||
} catch (e) { log('match phone error:', e.message) }
|
||
}
|
||
|
||
// 3) Civic + postal on Service Location → Customer (+ fetch Customer.language separately)
|
||
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) {
|
||
// Service Location doesn't carry language — fetch it from the linked Customer
|
||
let language = 'fr'
|
||
try {
|
||
const c = await erp.list('Customer', {
|
||
fields: ['language'], filters: [['name', '=', hit.customer]], limit: 1,
|
||
})
|
||
if (c?.[0]?.language) language = c[0].language
|
||
} catch {}
|
||
return { customer_id: hit.customer, customer_name: hit.customer_name || hit.customer,
|
||
language,
|
||
match_method: 'civic', match_confidence: 0.8 }
|
||
}
|
||
}
|
||
} catch (e) { log('match civic error:', e.message) }
|
||
}
|
||
|
||
// Unmatched: default to French (93% of customer base)
|
||
return { customer_id: null, customer_name: null, language: 'fr',
|
||
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.
|
||
// gift_clicked is the high-value engagement signal (recipient clicked the
|
||
// CTA button leading to the Giftbit shortlink), tracked separately from
|
||
// the generic 'clicked' status which fires on any link in the email.
|
||
const c = { ...campaign }
|
||
c.counters = (c.recipients || []).reduce((acc, r) => {
|
||
acc[r.status] = (acc[r.status] || 0) + 1
|
||
if (r.gift_link_clicked) acc.gift_clicked++
|
||
return acc
|
||
}, { total: (c.recipients || []).length, gift_clicked: 0 })
|
||
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)
|
||
})
|
||
}
|
||
|
||
// Templates are bundled inside the hub at services/targo-hub/templates/.
|
||
// Keep them in sync with scripts/campaigns/templates/ for the CLI use case.
|
||
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. Naming convention:
|
||
// gift-email-<lang>[-variant] — the recipient's language drives which one
|
||
// gets used at send time. `-simple` variants are flat-structured copies
|
||
// designed to parse cleanly in GrapesJS visual editor (no nested tables).
|
||
// Adding a new lang or variant = drop a new .html here + add to this list.
|
||
// Templates allow-list — moved from a hardcoded array to a regex-validated
|
||
// + disk-scan approach so the user can create new templates from the editor
|
||
// UI without us re-deploying the hub. The prefix list bounds what names are
|
||
// allowed (prevents arbitrary file writes outside the campaign domain).
|
||
const EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-', 'transactional-']
|
||
const TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ // lowercase, digits, dashes only
|
||
|
||
function isValidTemplateName (name) {
|
||
if (typeof name !== 'string' || !TEMPLATE_NAME_RE.test(name)) return false
|
||
return EDITABLE_TEMPLATE_PREFIXES.some(p => name.startsWith(p))
|
||
}
|
||
|
||
// Scan the templates dir and return every file matching our allow-list
|
||
// prefixes (.html OR .mjml — either format makes it editable). The .json
|
||
// design file alone doesn't count (it's a companion).
|
||
function scanEditableTemplates () {
|
||
if (!fs.existsSync(TEMPLATES_DIR)) return []
|
||
const seen = new Set()
|
||
for (const f of fs.readdirSync(TEMPLATES_DIR)) {
|
||
const m = f.match(/^([a-z0-9-]+)\.(html|mjml)$/)
|
||
if (!m) continue
|
||
if (m[1].includes('.bak-') || m[1].includes('.legacy-')) continue // skip backups
|
||
if (isValidTemplateName(m[1])) seen.add(m[1])
|
||
}
|
||
return [...seen].sort()
|
||
}
|
||
|
||
// Resolve the template path for a given recipient language. Falls back to
|
||
// the French version (most of the customer base) if the language-specific
|
||
// file doesn't exist. Per-campaign override via params.template_path skips
|
||
// language routing entirely.
|
||
function templateForLanguage (lang) {
|
||
const safe = (lang || 'fr').toLowerCase().split('-')[0] // 'fr-CA' → 'fr'
|
||
const candidate = path.join(TEMPLATES_DIR, `gift-email-${safe}.html`)
|
||
if (fs.existsSync(candidate)) return candidate
|
||
return DEFAULT_TEMPLATE
|
||
}
|
||
|
||
// Per-campaign per-language template override. Used when the wizard lets
|
||
// the operator pick a non-default variant (e.g. seasonal: gift-email-2026-summer-fr).
|
||
// The override is stored on params as a bare template name (no extension,
|
||
// no path) — we resolve to an absolute path here with a defensive name
|
||
// regex to block path traversal.
|
||
function resolveTemplatePath (p, lang) {
|
||
const safe = (lang || 'fr').toLowerCase().split('-')[0]
|
||
const tplName = safe === 'en' ? p?.template_en : p?.template_fr
|
||
if (tplName && /^[a-z0-9-]+$/i.test(tplName)) {
|
||
const candidate = path.join(TEMPLATES_DIR, tplName + '.html')
|
||
if (fs.existsSync(candidate)) return candidate
|
||
}
|
||
// Legacy single-template campaign-wide override
|
||
if (p?.template_path) return p.template_path
|
||
return templateForLanguage(lang)
|
||
}
|
||
|
||
// ── Gift redirect wrapper ───────────────────────────────────────────────────
|
||
// We don't put the raw Giftbit shortlink in outgoing emails any more. Instead
|
||
// each recipient gets a short opaque token, and the email contains
|
||
// https://msg.gigafibre.ca/g/<token>
|
||
// which 302-redirects to the underlying Giftbit URL — but ONLY if our own
|
||
// expiry hasn't passed and we haven't revoked it. This gives us:
|
||
// 1. End-date control independent of Giftbit's (e.g. expire after 30d
|
||
// even if Giftbit's underlying gift is valid for 12 months)
|
||
// 2. Reuse of unredeemed gifts: if recipient A doesn't click, we generate
|
||
// a NEW token for recipient B pointing to the same Giftbit URL. A's
|
||
// old wrapper URL stops working (expired/revoked) while B's new one
|
||
// directs to the same Giftbit gift.
|
||
//
|
||
// In-memory index `tokenIndex` is the fast lookup; the source of truth lives
|
||
// inside each campaign's recipient row (gift_token, gift_url, gift_expires_at,
|
||
// gift_revoked, gift_redirected_count, gift_first_redirected_at). The index
|
||
// is rebuilt at startup by scanning all campaign JSONs.
|
||
|
||
const tokenIndex = new Map() // token → { campaign_id, row }
|
||
|
||
function generateGiftToken () {
|
||
// 10 base64url chars ≈ 60 bits entropy — collision-safe for our volumes
|
||
// (millions of tokens × billion years before a clash). Avoids ambiguity
|
||
// characters (0/O, 1/l/I) accidentally — base64url already does.
|
||
return crypto.randomBytes(8).toString('base64url').slice(0, 10)
|
||
}
|
||
|
||
function rebuildTokenIndex () {
|
||
tokenIndex.clear()
|
||
let n = 0
|
||
for (const meta of listCampaigns()) {
|
||
const c = loadCampaign(meta.id)
|
||
if (!c?.recipients) continue
|
||
for (let i = 0; i < c.recipients.length; i++) {
|
||
const tok = c.recipients[i].gift_token
|
||
if (tok) { tokenIndex.set(tok, { campaign_id: c.id, row: i }); n++ }
|
||
}
|
||
}
|
||
log(`gift token index rebuilt: ${n} tokens across ${listCampaigns().length} campaigns`)
|
||
}
|
||
|
||
function lookupGiftToken (token) {
|
||
const ref = tokenIndex.get(token)
|
||
if (!ref) return null
|
||
const c = loadCampaign(ref.campaign_id)
|
||
if (!c?.recipients?.[ref.row]) return null
|
||
return { campaign: c, row: ref.row, recipient: c.recipients[ref.row] }
|
||
}
|
||
|
||
// Renders the wrapper URL for a recipient. Falls back to the raw gift_url
|
||
// when no token is set (backwards-compat with campaigns sent before this
|
||
// feature shipped — those emails already left and contain the raw URL).
|
||
function wrapperUrl (token) {
|
||
return `${cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'}/g/${encodeURIComponent(token)}`
|
||
}
|
||
|
||
// Static HTML returned when a token is expired/revoked/unknown. Branded but
|
||
// minimal so a 60s loading time from a corporate proxy doesn't reveal
|
||
// internal structure.
|
||
function giftExpiredPage (reason) {
|
||
const messages = {
|
||
expired: { fr: 'Ce cadeau a expiré.', en: 'This gift has expired.' },
|
||
revoked: { fr: 'Ce lien a été désactivé.', en: 'This link has been revoked.' },
|
||
notfound: { fr: 'Lien introuvable.', en: 'Link not found.' },
|
||
}
|
||
const m = messages[reason] || messages.notfound
|
||
return `<!doctype html><html lang="fr"><head><meta charset="utf-8">
|
||
<title>TARGO — ${m.fr}</title>
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<meta name="robots" content="noindex,nofollow">
|
||
<style>
|
||
body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#F5FAF7;color:#1B2E24;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||
.card{background:#fff;max-width:480px;margin:24px;padding:48px 32px;border-radius:16px;border:1px solid #e5e7eb;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||
.logo{font-size:28px;font-weight:800;color:#00C853;margin-bottom:24px;letter-spacing:-.5px}
|
||
.icon{font-size:48px;margin-bottom:16px}
|
||
h1{font-size:22px;margin:0 0 8px;font-weight:700}
|
||
p{color:#64748B;margin:0;line-height:1.5}
|
||
.en{margin-top:16px;font-size:14px;color:#94a3b8}
|
||
a{color:#00C853;text-decoration:none;font-weight:600}
|
||
</style></head><body>
|
||
<div class="card">
|
||
<div class="logo">TARGO</div>
|
||
<div class="icon">🎁</div>
|
||
<h1>${m.fr}</h1>
|
||
<p>Si tu penses qu'il s'agit d'une erreur, écris-nous à <a href="mailto:support@targo.ca">support@targo.ca</a>.</p>
|
||
<p class="en">${m.en}</p>
|
||
</div></body></html>`
|
||
}
|
||
|
||
function templatePath (name) {
|
||
if (!isValidTemplateName(name)) {
|
||
throw new Error(`template name invalid or not allowed: ${name}`)
|
||
}
|
||
return path.join(TEMPLATES_DIR, name + '.html')
|
||
}
|
||
|
||
// Companion .mjml path for a template name. If this file exists, the template
|
||
// is in MJML mode — the editor loads the .mjml source and on save we
|
||
// recompile to .html. If only the .html exists, classic HTML mode.
|
||
function templateMjmlPath (name) {
|
||
if (!isValidTemplateName(name)) {
|
||
throw new Error(`template name invalid or not allowed: ${name}`)
|
||
}
|
||
return path.join(TEMPLATES_DIR, name + '.mjml')
|
||
}
|
||
|
||
// Companion .json — visual editor's design tree (now Unlayer's design JSON,
|
||
// previously was easy-email's tree — same file, different content). When
|
||
// present, the editor uses it as the source of truth on load. Generated
|
||
// client-side by the editor on save; we just persist it as-is.
|
||
function templateJsonPath (name) {
|
||
if (!isValidTemplateName(name)) {
|
||
throw new Error(`template name invalid or not allowed: ${name}`)
|
||
}
|
||
return path.join(TEMPLATES_DIR, name + '.json')
|
||
}
|
||
|
||
// Return 'mjml' if the template has a .mjml companion file on disk (source
|
||
// of truth = MJML, .html is the auto-compiled output). Otherwise 'html'.
|
||
function templateFormat (name) {
|
||
try { return fs.existsSync(templateMjmlPath(name)) ? 'mjml' : 'html' }
|
||
catch { return 'html' }
|
||
}
|
||
|
||
function listEditableTemplates () {
|
||
return scanEditableTemplates().map(name => {
|
||
const format = templateFormat(name)
|
||
const p = format === 'mjml' ? templateMjmlPath(name) : 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, format, 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 throttle = parseInt(p.throttle_ms || 600, 10)
|
||
// Pre-load templates by language to avoid re-reading from disk on every
|
||
// recipient. Cache map keyed by resolved template path.
|
||
const tplCache = new Map()
|
||
const getTpl = (lang) => {
|
||
const tplPath = resolveTemplatePath(p, lang)
|
||
if (!tplCache.has(tplPath)) {
|
||
tplCache.set(tplPath, fs.readFileSync(tplPath, 'utf8'))
|
||
}
|
||
return tplCache.get(tplPath)
|
||
}
|
||
|
||
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 lang = (r.language || 'fr').toLowerCase().split('-')[0]
|
||
const tplText = getTpl(lang)
|
||
// Web fallback ("View in browser") so recipients with rendering
|
||
// issues (image-blocking, antique Outlook, third-party mail apps)
|
||
// can open the campaign in any modern browser. The URL hits the
|
||
// /recipients/:row_index/view endpoint defined further down which
|
||
// re-renders the same template with this recipient's variables.
|
||
const viewUrl = `${cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'}/campaigns/${encodeURIComponent(id)}/recipients/${i}/view`
|
||
// Per-recipient amount override. Precedence:
|
||
// 1. r.amount — explicit override typed in the manual-add dialog
|
||
// 2. r.gift_value_cents → "$X" formatted (when CSV import set this)
|
||
// 3. p.amount — campaign default ("60 $" etc.)
|
||
// 4. "50 $" hard fallback
|
||
// This matters for manual recipients where a user pastes a $50 Giftbit
|
||
// link into a campaign whose params.amount defaults to "60 $".
|
||
let displayAmount = p.amount || '50 $'
|
||
if (r.amount) {
|
||
displayAmount = r.amount
|
||
} else if (r.gift_value_cents) {
|
||
const cents = Number(r.gift_value_cents) || 0
|
||
// Cents are exact dollars when divisible, else 2-decimal
|
||
displayAmount = cents % 100 === 0
|
||
? `${cents / 100} $`
|
||
: `${(cents / 100).toFixed(2)} $`
|
||
}
|
||
// Generate the wrapper token if absent (idempotent across retries).
|
||
// Computed BEFORE the gift_url var so the email gets the wrapped URL.
|
||
if (!r.gift_token && r.gift_url) {
|
||
r.gift_token = generateGiftToken()
|
||
const expiryDays = parseInt(p.gift_expiry_days || 90, 10)
|
||
r.gift_expires_at = new Date(Date.now() + expiryDays * 86400 * 1000).toISOString()
|
||
r.gift_revoked = false
|
||
r.gift_redirected_count = 0
|
||
tokenIndex.set(r.gift_token, { campaign_id: id, row: i })
|
||
}
|
||
// Format the wrapper's own expiry date for display in the email body.
|
||
// Locale matches the recipient's language so "May 31, 2026" / "31 mai 2026"
|
||
// appear naturally. expires_in_days is a friendlier shorter format for
|
||
// tight deadlines (≤ 60 days). The two are intentionally redundant
|
||
// so the template author can pick the one that fits their layout.
|
||
const expiryLocale = lang === 'en' ? 'en-CA' : 'fr-CA'
|
||
let expiresAtDate = ''
|
||
let expiresInDays = ''
|
||
if (r.gift_expires_at) {
|
||
const exp = new Date(r.gift_expires_at)
|
||
expiresAtDate = exp.toLocaleDateString(expiryLocale, { day: 'numeric', month: 'long', year: 'numeric' })
|
||
const days = Math.max(0, Math.ceil((exp - new Date()) / 86400000))
|
||
expiresInDays = String(days)
|
||
}
|
||
const vars = {
|
||
firstname: r.firstname || (lang === 'en' ? 'dear customer' : 'cher client'),
|
||
lastname: r.lastname || '',
|
||
email: r.email,
|
||
description: r.civic_address || '',
|
||
// Wrapper URL when we have a token; fall back to the raw Giftbit URL
|
||
// (backwards-compat for recipients sent before this feature shipped).
|
||
gift_url: r.gift_token ? wrapperUrl(r.gift_token) : r.gift_url,
|
||
amount: displayAmount,
|
||
expiry: p.expiry || '',
|
||
// Auto-derived from the wrapper's gift_expires_at — distinct from
|
||
// the manual {{expiry}} field above which is for promotion-end dates
|
||
// unrelated to the gift link wrapper.
|
||
expires_at_date: expiresAtDate,
|
||
expires_in_days: expiresInDays,
|
||
commitment_months: p.commitment_months || '3',
|
||
year: new Date().getFullYear(),
|
||
view_url: viewUrl,
|
||
}
|
||
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 toi, 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 || '')
|
||
|
||
// Mailjet click events include the original URL the recipient clicked. We
|
||
// distinguish "clicked the gift CTA" (the actionable engagement signal)
|
||
// from "clicked the support email / footer link" (low-signal). Match the
|
||
// recipient's stored gift_url first (exact), with a host-level fallback
|
||
// for shortlink redirects (gft.link / giftbit.com).
|
||
function isGiftClick (r, ev) {
|
||
const clickedUrl = String(ev.url || ev.URL || '')
|
||
if (!clickedUrl) return false
|
||
// Wrapper URL match (current behaviour — emails sent after the gift
|
||
// redirect rollout contain our /g/<token> link, not the raw Giftbit one).
|
||
if (r.gift_token && clickedUrl.includes(`/g/${r.gift_token}`)) return true
|
||
// Legacy emails sent before the wrapper shipped still have the raw URL.
|
||
if (r.gift_url && clickedUrl.startsWith(r.gift_url)) return true
|
||
return /(?:^|\/\/)(?:[\w-]+\.)?(?:gft\.link|giftbit\.com)/i.test(clickedUrl)
|
||
}
|
||
|
||
// 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()
|
||
// Flag the high-value signal — recipient engaged with the offer.
|
||
// Once true, stays true (later non-gift clicks don't unset it).
|
||
if (isGiftClick(r, ev) && !r.gift_link_clicked) {
|
||
r.gift_link_clicked = true
|
||
r.gift_clicked_at = r.clicked_at
|
||
}
|
||
}
|
||
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 (isGiftClick(r, ev) && !r.gift_link_clicked) {
|
||
r.gift_link_clicked = true
|
||
r.gift_clicked_at = r.clicked_at
|
||
}
|
||
}
|
||
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/gifts — flattened inventory of every gift across every
|
||
// campaign so operators can see which Giftbit shortlinks are still
|
||
// unredeemed and reassignable. Cross-campaign view; lighter than the
|
||
// full campaign JSONs (no template params, no recipient PII beyond name+email).
|
||
// ORDER MATTERS: this route must come BEFORE the /campaigns/:id wildcard.
|
||
if (path === '/campaigns/gifts' && method === 'GET') {
|
||
const now = Date.now()
|
||
const rows = []
|
||
for (const meta of listCampaigns()) {
|
||
const c = loadCampaign(meta.id)
|
||
if (!c?.recipients) continue
|
||
for (let i = 0; i < c.recipients.length; i++) {
|
||
const r = c.recipients[i]
|
||
if (!r.gift_url) continue
|
||
const expired = r.gift_expires_at && new Date(r.gift_expires_at).getTime() < now
|
||
// Status taxonomy for the inventory UI:
|
||
// redeemed — recipient already clicked through to Giftbit (we
|
||
// don't know if they actually redeemed; would need
|
||
// the Giftbit /gifts/{uuid} poll, task #25)
|
||
// revoked — manually killed by an operator
|
||
// expired — our own gift_expires_at has passed; reassignable
|
||
// active — still live, may be clicked any moment
|
||
// pending — not yet sent (no gift_token generated yet)
|
||
let status = 'pending'
|
||
if (r.gift_token) {
|
||
status = 'active'
|
||
if (r.gift_revoked) status = 'revoked'
|
||
else if (expired) status = 'expired'
|
||
if (r.gift_link_clicked) status = 'redeemed'
|
||
}
|
||
rows.push({
|
||
campaign_id: c.id,
|
||
campaign_name: c.name,
|
||
row_index: i,
|
||
firstname: r.firstname, lastname: r.lastname, email: r.email,
|
||
gift_token: r.gift_token || null,
|
||
gift_url: r.gift_url,
|
||
giftbit_uuid: r.giftbit_uuid,
|
||
gift_expires_at: r.gift_expires_at || null,
|
||
gift_revoked: !!r.gift_revoked,
|
||
gift_redirected_count: r.gift_redirected_count || 0,
|
||
gift_first_redirected_at: r.gift_first_redirected_at || null,
|
||
gift_link_clicked: !!r.gift_link_clicked,
|
||
gift_clicked_at: r.gift_clicked_at || null,
|
||
status,
|
||
})
|
||
}
|
||
}
|
||
return json(res, 200, { gifts: rows })
|
||
}
|
||
|
||
// POST /campaigns/:id/recipients/:row/revoke — kill switch for a single
|
||
// wrapper token. Sets gift_revoked=true so /g/<token> returns the
|
||
// "désactivé" page. Used when an operator wants to free a Giftbit URL
|
||
// for reassignment before its natural expiry.
|
||
const revokeMatch = path.match(/^\/campaigns\/([^/]+)\/recipients\/(\d+)\/revoke$/)
|
||
if (revokeMatch && method === 'POST') {
|
||
const c = loadCampaign(revokeMatch[1])
|
||
if (!c) return json(res, 404, { error: 'not found' })
|
||
const i = parseInt(revokeMatch[2], 10)
|
||
const r = (c.recipients || [])[i]
|
||
if (!r) return json(res, 404, { error: 'recipient not found' })
|
||
if (!r.gift_token) return json(res, 400, { error: 'no token to revoke' })
|
||
r.gift_revoked = true
|
||
r.gift_revoked_at = new Date().toISOString()
|
||
saveCampaign(c)
|
||
log(`gift token ${r.gift_token} revoked (campaign ${c.id} row ${i})`)
|
||
sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i, recipient: r })
|
||
return json(res, 200, { revoked: true, gift_token: r.gift_token })
|
||
}
|
||
|
||
// 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 template content (HTML or MJML
|
||
// source depending on what's stored). The `format` field tells the editor
|
||
// which mode to load (grapesjs-mjml for mjml, preset-newsletter for html).
|
||
const tplGet = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)$/)
|
||
if (tplGet && method === 'GET') {
|
||
const name = tplGet[1]
|
||
try {
|
||
const format = templateFormat(name)
|
||
// Always return: name, format, html (canonical, what gets sent), and
|
||
// design (Unlayer/visual-editor JSON tree if present, for re-edit).
|
||
// For legacy MJML templates the .mjml is also returned for reference.
|
||
const html = fs.readFileSync(templatePath(name), 'utf8')
|
||
let design = null
|
||
try {
|
||
const jsonStr = fs.readFileSync(templateJsonPath(name), 'utf8')
|
||
design = JSON.parse(jsonStr)
|
||
} catch {}
|
||
const out = { name, format, html, design }
|
||
if (format === 'mjml') {
|
||
try { out.mjml = fs.readFileSync(templateMjmlPath(name), 'utf8') } catch {}
|
||
}
|
||
return json(res, 200, out)
|
||
} catch (e) {
|
||
return json(res, 404, { error: 'template not found', detail: e.message })
|
||
}
|
||
}
|
||
|
||
// PUT /campaigns/templates/:name — save updated template content.
|
||
// Accepts EITHER:
|
||
// { mjml: "<mjml>...</mjml>" } — compile to HTML server-side, save both
|
||
// { html: "<html>..." } — save HTML directly (legacy path)
|
||
//
|
||
// Keeps the previous version as <name>.bak-<ts>.<ext> so a bad edit can
|
||
// be rolled back without git access.
|
||
if (tplGet && method === 'PUT') {
|
||
const body = await parseBody(req)
|
||
const name = tplGet[1]
|
||
if (!body || (typeof body.html !== 'string' && typeof body.mjml !== 'string')) {
|
||
return json(res, 400, { error: 'html OR mjml string required' })
|
||
}
|
||
try {
|
||
// ── MJML save path ──
|
||
if (typeof body.mjml === 'string' && body.mjml) {
|
||
const mjmlPath = templateMjmlPath(name)
|
||
const htmlPath = templatePath(name)
|
||
const jsonPath = templateJsonPath(name)
|
||
// Compile MJML → HTML (async in mjml v5)
|
||
const r = await mjml2html(body.mjml, { validationLevel: 'soft' })
|
||
if (r.errors?.length) {
|
||
return json(res, 400, {
|
||
error: 'MJML validation failed',
|
||
details: r.errors.map(e => e.formattedMessage || e.message),
|
||
})
|
||
}
|
||
// Backup all 3 companion files before overwriting
|
||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||
try {
|
||
if (fs.existsSync(mjmlPath)) fs.copyFileSync(mjmlPath, mjmlPath.replace(/\.mjml$/, `.bak-${ts}.mjml`))
|
||
if (fs.existsSync(htmlPath)) fs.copyFileSync(htmlPath, htmlPath.replace(/\.html$/, `.bak-${ts}.html`))
|
||
if (fs.existsSync(jsonPath)) fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`))
|
||
} catch (e) { log('template backup failed:', e.message) }
|
||
fs.writeFileSync(mjmlPath, body.mjml, 'utf8')
|
||
fs.writeFileSync(htmlPath, r.html, 'utf8')
|
||
// Optional editor-state snapshot — only written when client sends it
|
||
// (the React editor does, but the legacy HTML editor doesn't).
|
||
let json_size = 0
|
||
if (body.json) {
|
||
const jsonStr = typeof body.json === 'string' ? body.json : JSON.stringify(body.json)
|
||
fs.writeFileSync(jsonPath, jsonStr, 'utf8')
|
||
json_size = jsonStr.length
|
||
}
|
||
log(`template ${name} updated (mjml: ${body.mjml.length}b → html: ${r.html.length}b${json_size ? ', json: ' + json_size + 'b' : ''})`)
|
||
return json(res, 200, {
|
||
name, format: 'mjml', saved: true,
|
||
mjml_size: body.mjml.length, html_size: r.html.length, json_size,
|
||
})
|
||
}
|
||
// ── HTML save path (now the PRIMARY flow — Unlayer outputs HTML directly) ──
|
||
const p = templatePath(name)
|
||
const jsonPath = templateJsonPath(name)
|
||
try {
|
||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||
if (fs.existsSync(p)) fs.copyFileSync(p, p.replace(/\.html$/, `.bak-${ts}.html`))
|
||
if (fs.existsSync(jsonPath)) fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`))
|
||
} catch (e) { log('template backup failed:', e.message) }
|
||
fs.writeFileSync(p, body.html, 'utf8')
|
||
// Persist editor design JSON alongside (Unlayer's loadDesign() input)
|
||
let design_size = 0
|
||
if (body.design) {
|
||
const designStr = typeof body.design === 'string' ? body.design : JSON.stringify(body.design)
|
||
fs.writeFileSync(jsonPath, designStr, 'utf8')
|
||
design_size = designStr.length
|
||
}
|
||
log(`template ${name} updated (html: ${body.html.length}b${design_size ? ', design: ' + design_size + 'b' : ''})`)
|
||
return json(res, 200, {
|
||
name, format: 'html', saved: true,
|
||
size: body.html.length, design_size,
|
||
})
|
||
} catch (e) {
|
||
return json(res, 400, { error: e.message })
|
||
}
|
||
}
|
||
|
||
// POST /campaigns/templates/:name/translate-to/:targetName — translate the
|
||
// source template's HTML content via Gemini Flash and save as targetName.
|
||
// Preserves HTML structure, Mustache {{vars}}, URLs, brand names, emojis.
|
||
// Body (optional): { override: true } to overwrite an existing target.
|
||
const tplTranslate = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)\/translate-to\/([a-zA-Z0-9_-]+)$/)
|
||
if (tplTranslate && method === 'POST') {
|
||
const srcName = tplTranslate[1]
|
||
const targetName = tplTranslate[2]
|
||
if (!isValidTemplateName(srcName) || !isValidTemplateName(targetName)) {
|
||
return json(res, 400, { error: 'invalid source or target template name' })
|
||
}
|
||
if (srcName === targetName) {
|
||
return json(res, 400, { error: 'source and target must differ' })
|
||
}
|
||
// Detect direction from name suffix (-fr → translate to en, -en → fr)
|
||
// Falls back to the explicit target language inferred from target name.
|
||
const srcLang = srcName.match(/-([a-z]{2})(-|$)/)?.[1] || 'fr'
|
||
const tgtLang = targetName.match(/-([a-z]{2})(-|$)/)?.[1] || (srcLang === 'fr' ? 'en' : 'fr')
|
||
let srcHtml
|
||
try { srcHtml = fs.readFileSync(templatePath(srcName), 'utf8') }
|
||
catch { return json(res, 404, { error: 'source template not found' }) }
|
||
|
||
const body = await parseBody(req).catch(() => ({}))
|
||
const targetHtmlPath = templatePath(targetName)
|
||
if (fs.existsSync(targetHtmlPath) && !body?.override) {
|
||
return json(res, 409, {
|
||
error: 'target template already exists',
|
||
hint: 'POST again with { "override": true } to overwrite',
|
||
})
|
||
}
|
||
|
||
const langNames = { fr: 'French Canadian (Quebec)', en: 'English (North American)' }
|
||
// System prompt: emphasize MEANING + TONE + IDIOMATIC REPHRASING over
|
||
// literal word-by-word translation. The previous version produced robotic
|
||
// output because it forced byte-for-byte preservation, which suppressed
|
||
// Gemini's natural rephrasing capability. New approach: rephrase freely
|
||
// within the constraint of preserving HTML structure + technical content.
|
||
const systemPrompt = [
|
||
`You are a senior MARKETING COPYWRITER, not a literal translator. You`,
|
||
`craft email copy for TARGO, a regional Quebec-based fiber Internet ISP`,
|
||
`that prides itself on local presence, warmth, and trust. You're`,
|
||
`localizing email content between Quebec French and North American English.`,
|
||
``,
|
||
`══ YOUR MISSION ══`,
|
||
`Recreate the MEANING and EMOTIONAL IMPACT of the source in the target`,
|
||
`language. You are NOT translating words — you are rewriting marketing`,
|
||
`copy that lands the same way for a different audience. Feel free to:`,
|
||
` • Rephrase sentences entirely`,
|
||
` • Use idioms native to the target audience`,
|
||
` • Shorten or split sentences for clarity`,
|
||
` • Change metaphors if the original doesn't translate culturally`,
|
||
` • Switch passive↔active voice freely`,
|
||
``,
|
||
`══ TONE (HARD CONSTRAINT) ══`,
|
||
`Warm, conversational, slightly playful. Like a neighbor explaining`,
|
||
`something — never corporate, never stiff. Informal "tu" in French and`,
|
||
`informal "you" in English. Use contractions ("we're", "you'll", "on est").`,
|
||
``,
|
||
`══ STRUCTURAL PRESERVATION (HARD CONSTRAINT) ══`,
|
||
`Keep IDENTICAL byte-for-byte:`,
|
||
` 1. ALL HTML tags, attributes, classes, inline styles, Outlook MSO`,
|
||
` conditional comments`,
|
||
` 2. Mustache placeholders: {{firstname}}, {{amount}}, {{gift_url}},`,
|
||
` {{commitment_months}}, {{description}}, {{expiry}}, {{year}}`,
|
||
` 3. URLs, email addresses, phone numbers, hex colors, CSS, font names`,
|
||
` 4. Brand names: TARGO, Gigafibre, Giftbit, Mailjet, Amazon, IGA,`,
|
||
` Tim Hortons, Pizza Pizza, Home Depot, Best Buy, Walmart,`,
|
||
` Petro-Canada, Esso, Home Hardware, Sobeys`,
|
||
` 5. ALL emojis: 🎁 ⚡ 🤝 🪂 ✅ ⏭️ ⏰`,
|
||
` 6. Technical values like "3.5 Gbit/s", "7j/7" → "7 days/week"`,
|
||
``,
|
||
`══ FEW-SHOT EXAMPLES (THIS IS THE STYLE I WANT) ══`,
|
||
``,
|
||
`FR: "On veut te remercier pour ta loyauté envers l'achat local."`,
|
||
`EN: "Thanks for keeping it local — it means more than you think."`,
|
||
` (NOT: "We want to thank you for your loyalty to local shopping.")`,
|
||
``,
|
||
`FR: "Comme toi, on aime les connexions stables et les relations durables."`,
|
||
`EN: "Like you, we believe in steady connections — both the fiber kind and the human kind."`,
|
||
` (NOT: "Like you, we love stable connections and lasting relationships.")`,
|
||
``,
|
||
`FR: "On est juste à côté et on aime aider."`,
|
||
`EN: "We're right next door — and we genuinely love lending a hand."`,
|
||
` (NOT: "We are next to you and we love to help.")`,
|
||
``,
|
||
`FR: "Avec l'arrivée de l'été, voici un cadeau pour toi, disponible pour un temps limité."`,
|
||
`EN: "Summer's here, and we've got something for you — but only for a little while."`,
|
||
``,
|
||
`══ OUTPUT FORMAT ══`,
|
||
`Output the COMPLETE translated HTML from <!doctype html> to </html>.`,
|
||
`NO commentary, NO markdown fences, NO preamble. Just the HTML.`,
|
||
].join('\n')
|
||
|
||
const userPrompt = `Localize this ${langNames[srcLang]} marketing email into ${langNames[tgtLang]}. ` +
|
||
`Apply the COPYWRITER mindset from the system prompt — rephrase for natural flow, ` +
|
||
`keep the warm tone. Preserve every HTML tag and Mustache placeholder exactly.\n\n${srcHtml}`
|
||
|
||
let translated
|
||
try {
|
||
translated = await getAi().aiCall(systemPrompt, userPrompt, {
|
||
jsonMode: false,
|
||
maxTokens: 32768,
|
||
temperature: 0.7, // bumped from 0.2 — let the AI rephrase creatively
|
||
})
|
||
} catch (e) {
|
||
return json(res, 502, { error: 'AI translation failed', detail: e.message })
|
||
}
|
||
|
||
// Sanity checks — refuse to save garbled output
|
||
if (typeof translated !== 'string' || translated.length < srcHtml.length * 0.5) {
|
||
return json(res, 502, {
|
||
error: 'AI returned truncated or invalid output',
|
||
src_bytes: srcHtml.length,
|
||
out_bytes: typeof translated === 'string' ? translated.length : 0,
|
||
})
|
||
}
|
||
// Strip optional markdown code fences if Gemini still added them despite the prompt
|
||
translated = translated.replace(/^```(?:html)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim()
|
||
|
||
// Backup existing target before overwriting
|
||
try {
|
||
if (fs.existsSync(targetHtmlPath)) {
|
||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||
fs.copyFileSync(targetHtmlPath, targetHtmlPath.replace(/\.html$/, `.bak-${ts}.html`))
|
||
const jsonPath = templateJsonPath(targetName)
|
||
if (fs.existsSync(jsonPath)) fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`))
|
||
}
|
||
} catch (e) { log('translate backup failed:', e.message) }
|
||
|
||
// Write translated HTML
|
||
fs.writeFileSync(targetHtmlPath, translated, 'utf8')
|
||
|
||
// Regenerate the Unlayer design JSON via the converter so the editor
|
||
// can reload the translated template visually. Inline equivalent of
|
||
// running scripts/convert-html-to-unlayer.js for this target.
|
||
const bodyMatch = translated.match(/<body[^>]*>([\s\S]*?)<\/body>/i)
|
||
const innerHtml = bodyMatch ? bodyMatch[1].trim() : translated
|
||
const preheaderMatch = innerHtml.match(/<div[^>]*display:\s*none[^>]*>\s*([^<]+?)\s*<\/div>/i)
|
||
const preheader = preheaderMatch ? preheaderMatch[1].trim() : ''
|
||
const design = {
|
||
counters: { u_row: 1, u_column: 1, u_content_html: 1 },
|
||
body: {
|
||
id: 'BODY-1',
|
||
rows: [{
|
||
id: 'ROW-1', cells: [1],
|
||
columns: [{
|
||
id: 'COL-1',
|
||
contents: [{
|
||
id: 'HTML-1', type: 'html',
|
||
values: {
|
||
html: innerHtml, hideDesktop: false, displayCondition: null,
|
||
containerPadding: '0px',
|
||
_meta: { htmlID: 'u_content_html_1', htmlClassNames: 'u_content_html' },
|
||
selectable: true, draggable: true, duplicatable: true, deletable: true, hideable: true,
|
||
},
|
||
}],
|
||
values: { _meta: { htmlID: 'u_column_1', htmlClassNames: 'u_column' } },
|
||
}],
|
||
values: {
|
||
displayCondition: null, columns: false, backgroundColor: '', padding: '0px',
|
||
_meta: { htmlID: 'u_row_1', htmlClassNames: 'u_row' },
|
||
selectable: true, draggable: true, duplicatable: true, deletable: true, hideable: true,
|
||
},
|
||
}],
|
||
values: {
|
||
contentWidth: '600px',
|
||
fontFamily: { label: 'Plus Jakarta Sans', value: "'Plus Jakarta Sans', sans-serif" },
|
||
textColor: '#1B2E24',
|
||
backgroundColor: '#F5FAF7',
|
||
preheaderText: preheader,
|
||
linkStyle: { body: true, linkColor: '#00C853', linkUnderline: true },
|
||
_meta: { htmlID: 'u_body', htmlClassNames: 'u_body' },
|
||
},
|
||
},
|
||
schemaVersion: 12,
|
||
}
|
||
fs.writeFileSync(templateJsonPath(targetName), JSON.stringify(design, null, 2), 'utf8')
|
||
|
||
log(`translate: ${srcName} (${srcHtml.length}b ${srcLang}) → ${targetName} (${translated.length}b ${tgtLang})`)
|
||
return json(res, 200, {
|
||
source: srcName,
|
||
target: targetName,
|
||
from_lang: srcLang,
|
||
to_lang: tgtLang,
|
||
src_bytes: srcHtml.length,
|
||
out_bytes: translated.length,
|
||
saved: true,
|
||
})
|
||
}
|
||
|
||
// POST /campaigns/templates/:name/test-send — send ONE rendered email to a
|
||
// specific address for visual QA. Uses the campaign Mailjet sender +
|
||
// throttle. NOT linked to any campaign — purely for template testing.
|
||
// Body: { to: 'louis@targo.ca', vars: {...}, from?: 'TARGO <support@targointernet.com>' }
|
||
const tplTestSend = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)\/test-send$/)
|
||
if (tplTestSend && method === 'POST') {
|
||
const body = await parseBody(req)
|
||
const to = (body.to || '').trim()
|
||
if (!to || !to.includes('@')) {
|
||
return json(res, 400, { error: 'to (recipient email) required' })
|
||
}
|
||
const name = tplTestSend[1]
|
||
let html
|
||
try {
|
||
// Always read the COMPILED .html — what the recipient actually receives.
|
||
// MJML source is for the editor; the send-worker reads compiled HTML.
|
||
html = fs.readFileSync(templatePath(name), 'utf8')
|
||
} catch (e) {
|
||
return json(res, 404, { error: 'template not found', detail: e.message })
|
||
}
|
||
const vars = {
|
||
firstname: 'Louis',
|
||
lastname: 'Test',
|
||
email: to,
|
||
description: '123 Rue de Test, Ste-Clotilde',
|
||
gift_url: 'https://gft.link/TEST123',
|
||
amount: '60 $',
|
||
expiry: '31 décembre 2026',
|
||
commitment_months: '3',
|
||
year: new Date().getFullYear(),
|
||
...(body.vars || {}),
|
||
}
|
||
const rendered = renderTemplate(html, vars)
|
||
const fromAddr = body.from || cfg.MAIL_FROM || 'TARGO <support@targointernet.com>'
|
||
try {
|
||
const info = await email.sendEmail({
|
||
to,
|
||
from: fromAddr,
|
||
subject: body.subject || `[TEST] Template ${name}`,
|
||
html: rendered,
|
||
headers: { 'X-MJ-CustomID': `test-send:${name}:${Date.now()}` },
|
||
})
|
||
if (!info) return json(res, 500, { error: 'SMTP send returned false (see hub logs)' })
|
||
log(`test-send: ${name} → ${to} (${rendered.length} bytes)`)
|
||
return json(res, 200, {
|
||
sent: true, to, from: fromAddr,
|
||
message_id: info.messageId || null,
|
||
bytes: rendered.length,
|
||
})
|
||
} catch (e) {
|
||
return json(res, 500, { 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) })
|
||
}
|
||
|
||
// ── Image asset upload (self-hosted, for new images added via the editor) ──
|
||
// Existing Mailjet-hosted brand logos in the templates are NOT affected —
|
||
// those stay on xqy3m.mjt.lu/img2/... This is for additional images the
|
||
// user uploads through the GrapesJS asset manager.
|
||
|
||
// POST /campaigns/assets/upload — accepts JSON { name, data } where data
|
||
// is a data:image/...;base64,... URL. Returns { url, filename, size }.
|
||
// Hash-based filename = automatic dedup + immutable URLs.
|
||
if (path === '/campaigns/assets/upload' && method === 'POST') {
|
||
const body = await parseBody(req)
|
||
if (!body || !body.data) return json(res, 400, { error: 'data field required (data URL)' })
|
||
const decoded = decodeDataUrl(body.data)
|
||
if (!decoded) return json(res, 415, { error: 'unsupported or invalid image data URL' })
|
||
if (decoded.error === 'too_large') {
|
||
return json(res, 413, { error: `image too large (${decoded.size}b, max ${MAX_UPLOAD_BYTES}b)` })
|
||
}
|
||
const filename = persistUpload(decoded.buffer, decoded.ext)
|
||
log(`asset upload: ${filename} (${decoded.buffer.length}b, name: ${body.name || 'n/a'})`)
|
||
const url = uploadUrl(filename, req)
|
||
return json(res, 200, {
|
||
filename, url, size: decoded.buffer.length, content_type: decoded.mime,
|
||
// GrapesJS asset manager expects this shape on upload success
|
||
data: [{ src: url, type: 'image' }],
|
||
})
|
||
}
|
||
|
||
// GET /campaigns/assets — list all uploaded assets with metadata
|
||
if (path === '/campaigns/assets' && method === 'GET') {
|
||
return json(res, 200, { assets: listUploads(req) })
|
||
}
|
||
|
||
// GET /campaigns/assets/:filename — serve image bytes
|
||
const assetGet = path.match(/^\/campaigns\/assets\/([a-f0-9]{64}\.(?:png|jpg|gif|webp|svg))$/)
|
||
if (assetGet && method === 'GET') {
|
||
const r = readUpload(assetGet[1])
|
||
if (!r) return json(res, 404, { error: 'asset not found' })
|
||
res.writeHead(200, {
|
||
'Content-Type': r.mime,
|
||
'Content-Length': r.buffer.length,
|
||
// Immutable cache: filename is content-hash, the bytes for a given URL
|
||
// never change. 1y cache aligns with how email image proxies work.
|
||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||
'Access-Control-Allow-Origin': '*',
|
||
})
|
||
return res.end(r.buffer)
|
||
}
|
||
|
||
// DELETE /campaigns/assets/:filename — remove from disk
|
||
if (assetGet && method === 'DELETE') {
|
||
if (deleteUpload(assetGet[1])) {
|
||
log(`asset deleted: ${assetGet[1]}`)
|
||
return json(res, 200, { deleted: assetGet[1] })
|
||
}
|
||
return json(res, 404, { error: 'asset not found' })
|
||
}
|
||
|
||
// 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/recipients/:i/view — re-render the email for ONE
|
||
// recipient using the same variables the worker used at send time. Linked
|
||
// from the "Voir dans le navigateur / View in browser" line at the top of
|
||
// every campaign email so recipients with rendering issues (image-blocking
|
||
// clients, antique Outlook, niche third-party mail apps) can fall back to
|
||
// a fresh browser render. No auth needed — the campaign-id is 21-char
|
||
// nanoid (≈10²¹ space) and row_index alone is enough to reconcile.
|
||
const viewMatch = path.match(/^\/campaigns\/([^/]+)\/recipients\/(\d+)\/view$/)
|
||
if (viewMatch && method === 'GET') {
|
||
const c = loadCampaign(viewMatch[1])
|
||
if (!c) return json(res, 404, { error: 'not found' })
|
||
const i = parseInt(viewMatch[2], 10)
|
||
const r = (c.recipients || [])[i]
|
||
if (!r) return json(res, 404, { error: 'recipient not found' })
|
||
const p = c.params || {}
|
||
const lang = (r.language || 'fr').toLowerCase().split('-')[0]
|
||
const tplPath = resolveTemplatePath(p, lang)
|
||
let tplText
|
||
try { tplText = fs.readFileSync(tplPath, 'utf8') }
|
||
catch { return json(res, 500, { error: 'template missing' }) }
|
||
let displayAmount = p.amount || '50 $'
|
||
if (r.amount) {
|
||
displayAmount = r.amount
|
||
} else if (r.gift_value_cents) {
|
||
const cents = Number(r.gift_value_cents) || 0
|
||
displayAmount = cents % 100 === 0
|
||
? `${cents / 100} $`
|
||
: `${(cents / 100).toFixed(2)} $`
|
||
}
|
||
// Locale-formatted wrapper expiry — same logic as the worker so the
|
||
// /view fallback renders the exact same date the recipient saw at first.
|
||
const expiryLocale = lang === 'en' ? 'en-CA' : 'fr-CA'
|
||
let expiresAtDate = ''
|
||
let expiresInDays = ''
|
||
if (r.gift_expires_at) {
|
||
const exp = new Date(r.gift_expires_at)
|
||
expiresAtDate = exp.toLocaleDateString(expiryLocale, { day: 'numeric', month: 'long', year: 'numeric' })
|
||
const days = Math.max(0, Math.ceil((exp - new Date()) / 86400000))
|
||
expiresInDays = String(days)
|
||
}
|
||
const vars = {
|
||
firstname: r.firstname || (lang === 'en' ? 'dear customer' : 'cher client'),
|
||
lastname: r.lastname || '',
|
||
email: r.email,
|
||
description: r.civic_address || '',
|
||
// Same wrapper logic as the worker so the in-browser fallback view
|
||
// links to the same URL the email did. Clicking from /view goes
|
||
// through our redirect, lets us honour expiry/revoke consistently.
|
||
gift_url: r.gift_token ? wrapperUrl(r.gift_token) : r.gift_url,
|
||
amount: displayAmount,
|
||
expiry: p.expiry || '',
|
||
expires_at_date: expiresAtDate,
|
||
expires_in_days: expiresInDays,
|
||
commitment_months: p.commitment_months || '3',
|
||
year: new Date().getFullYear(),
|
||
// Empty so the {{#view_url}} section block in the template collapses
|
||
// — we don't want the "view in browser" line to show up when the
|
||
// user is ALREADY viewing it in a browser.
|
||
view_url: '',
|
||
}
|
||
const html = renderTemplate(tplText, vars)
|
||
res.writeHead(200, {
|
||
'Content-Type': 'text/html; charset=utf-8',
|
||
'Cache-Control': 'no-store',
|
||
// Disallow indexing — these URLs aren't meant for search engines
|
||
'X-Robots-Tag': 'noindex, nofollow',
|
||
})
|
||
return res.end(html)
|
||
}
|
||
|
||
// GET /campaigns/:id/report.csv — per-recipient report download
|
||
// Columns chosen for operational follow-up (resend, refund, support):
|
||
// row, firstname, lastname, email, phone, language, customer_id, civic_address,
|
||
// postal_code, gift_value_cents, gift_url, giftbit_uuid, status, excluded,
|
||
// sent_at, opened_at, clicked_at, mailjet_uuid, error
|
||
// RFC 4180: comma-separated, CRLF endings, fields with comma/quote/newline get
|
||
// wrapped in double quotes with embedded " doubled up. We prepend a UTF-8 BOM
|
||
// so Excel auto-detects encoding and renders accents correctly.
|
||
const reportMatch = path.match(/^\/campaigns\/([^/]+)\/report\.csv$/)
|
||
if (reportMatch && method === 'GET') {
|
||
const c = loadCampaign(reportMatch[1])
|
||
if (!c) return json(res, 404, { error: 'not found' })
|
||
const headers = [
|
||
'row', 'firstname', 'lastname', 'email', 'phone', 'language', 'customer_id',
|
||
'civic_address', 'city', 'postal_code', 'gift_value_cents',
|
||
'gift_url', 'giftbit_uuid', 'giftbit_admin_url',
|
||
'gift_token', 'gift_expires_at', 'gift_revoked', 'gift_redirected_count', 'gift_first_redirected_at',
|
||
'status', 'excluded', 'sent_at', 'opened_at', 'clicked_at',
|
||
'gift_link_clicked', 'gift_clicked_at',
|
||
'mailjet_uuid', 'error',
|
||
]
|
||
const esc = (v) => {
|
||
if (v == null) return ''
|
||
const s = String(v)
|
||
return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
|
||
}
|
||
const lines = [headers.join(',')]
|
||
for (const r of (c.recipients || [])) {
|
||
// Build Giftbit admin search URL for manual redemption check
|
||
// (until /gifts/{uuid} API polling lands — see task #25).
|
||
let giftbitAdminUrl = ''
|
||
if (r.gift_url) {
|
||
const code = String(r.gift_url).replace(/\/+$/, '').split('/').pop()
|
||
if (code && code.length >= 4) {
|
||
giftbitAdminUrl = `https://app.giftbit.com/app/rewards?search=${encodeURIComponent(code)}`
|
||
}
|
||
}
|
||
lines.push([
|
||
r.row_index, r.firstname, r.lastname, r.email, r.phone, r.language, r.customer_id,
|
||
r.civic_address, r.city, r.postal_code, r.gift_value_cents,
|
||
r.gift_url, r.giftbit_uuid, giftbitAdminUrl,
|
||
r.gift_token, r.gift_expires_at, r.gift_revoked ? 'true' : 'false',
|
||
r.gift_redirected_count || 0, r.gift_first_redirected_at,
|
||
r.status, r.excluded ? 'true' : 'false', r.sent_at, r.opened_at, r.clicked_at,
|
||
r.gift_link_clicked ? 'true' : 'false', r.gift_clicked_at,
|
||
r.mailjet_uuid, r.error,
|
||
].map(esc).join(','))
|
||
}
|
||
const csv = '' + lines.join('\r\n') + '\r\n'
|
||
const safeName = (c.name || c.id).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 80)
|
||
res.writeHead(200, {
|
||
'Content-Type': 'text/csv; charset=utf-8',
|
||
'Content-Disposition': `attachment; filename="campaign-${safeName}.csv"`,
|
||
'Cache-Control': 'no-store',
|
||
})
|
||
return res.end(csv)
|
||
}
|
||
|
||
// 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' })
|
||
}
|
||
|
||
// DELETE /campaigns/:id — remove the campaign JSON from disk. Mostly for
|
||
// cleaning up test/draft runs; the gifts themselves live on Giftbit and
|
||
// are unaffected. Refuses to delete while the send worker is active for
|
||
// this id (would crash the worker on the next saveCampaign).
|
||
if (detailMatch && method === 'DELETE') {
|
||
const id = detailMatch[1]
|
||
if (activeWorkers.has(id)) {
|
||
return json(res, 409, { error: 'cannot delete while campaign is sending — wait for it to finish or fail' })
|
||
}
|
||
let filePath
|
||
try { filePath = campaignPath(id) }
|
||
catch { return json(res, 400, { error: 'invalid campaign id' }) }
|
||
if (!fs.existsSync(filePath)) return json(res, 404, { error: 'not found' })
|
||
try {
|
||
fs.unlinkSync(filePath)
|
||
log(`campaign ${id} deleted`)
|
||
sse.broadcast(`campaign:${id}`, 'campaign-deleted', { id })
|
||
return json(res, 200, { id, deleted: true })
|
||
} catch (e) {
|
||
log(`campaign ${id} delete failed:`, e.message)
|
||
return json(res, 500, { error: 'delete failed: ' + e.message })
|
||
}
|
||
}
|
||
|
||
return json(res, 404, { error: 'campaigns endpoint not found' })
|
||
}
|
||
|
||
// ── Gift redirect handler ────────────────────────────────────────────────────
|
||
// Public unauthenticated endpoint hit by recipients' email clicks.
|
||
// /g/:token → 302 to underlying Giftbit URL, OR branded expired page.
|
||
async function handleGiftRedirect (req, res, urlPath) {
|
||
const m = urlPath.match(/^\/g\/([A-Za-z0-9_-]{4,32})$/)
|
||
const respondExpired = (reason) => {
|
||
res.writeHead(reason === 'notfound' ? 404 : 410, {
|
||
'Content-Type': 'text/html; charset=utf-8',
|
||
'Cache-Control': 'no-store',
|
||
'X-Robots-Tag': 'noindex, nofollow',
|
||
})
|
||
res.end(giftExpiredPage(reason))
|
||
}
|
||
if (!m) return respondExpired('notfound')
|
||
const token = m[1]
|
||
const hit = lookupGiftToken(token)
|
||
if (!hit) return respondExpired('notfound')
|
||
const r = hit.recipient
|
||
if (r.gift_revoked) return respondExpired('revoked')
|
||
if (r.gift_expires_at && new Date(r.gift_expires_at) < new Date()) return respondExpired('expired')
|
||
if (!r.gift_url) return respondExpired('notfound')
|
||
|
||
// Successful redirect — record analytics, persist async (no await; the
|
||
// recipient is already on their way to Giftbit's page).
|
||
r.gift_redirected_count = (r.gift_redirected_count || 0) + 1
|
||
if (!r.gift_first_redirected_at) r.gift_first_redirected_at = new Date().toISOString()
|
||
try { saveCampaign(hit.campaign) } catch (e) { log(`gift redirect save failed: ${e.message}`) }
|
||
// Broadcast so the live campaign detail page updates the click counter
|
||
sse.broadcast(`campaign:${hit.campaign.id}`, 'recipient-update', { i: hit.row, recipient: r })
|
||
|
||
res.writeHead(302, {
|
||
Location: r.gift_url,
|
||
'Cache-Control': 'no-store',
|
||
'X-Robots-Tag': 'noindex, nofollow',
|
||
})
|
||
res.end()
|
||
}
|
||
|
||
// Rebuild the token index from disk on module load so a hub restart
|
||
// doesn't break existing wrapper URLs in already-sent emails.
|
||
rebuildTokenIndex()
|
||
|
||
module.exports = {
|
||
handle,
|
||
handleGiftRedirect,
|
||
// Exposed for testing
|
||
parseCsv, parseMapCsv, parseGiftbitCsv,
|
||
matchCustomer, normalizeCivic, normalizePhone, normalizePostal,
|
||
renderTemplate,
|
||
}
|