Two issues spotted during first real-data test:
1. parseGiftbitCsv only handled the Campaign-export format (header row
+ columns firstname/lastname/email/gift_url/uuid/...). The Link Order
product Giftbit ships when you pre-buy N links exports a different
format: headerless, one URL per line. Detect this by checking the
first non-empty line: if it starts with http(s):// and has no
comma/pipe/tab separators, treat the whole file as bare URLs. Each
URL maps to one recipient (row-order matching, same as before).
2. The template editor was hard-coded to load the existing
gift-email-fr.html into GrapesJS on mount. Hand-crafted email HTML
with deeply nested tables doesn't parse cleanly into GrapesJS
components, so the visual canvas often renders blank. Two new
toolbar actions to address this:
• "Vide" — clears the canvas to a minimal table-based skeleton.
For composing brand-new templates from scratch in the visual
editor without inheriting the existing template's structure.
Confirms before resetting, then sets dirty=true so the next Save
overwrites the on-disk template (with hub-side backup).
• "Réinitialiser" — reloads the last on-disk version, discarding
any unsaved canvas state. Confirms if dirty.
Plus an amber banner in visual mode (auto-hidden when blank-canvas
is active) explaining that Visual mode is for new templates and
the existing template should be edited in HTML mode.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The /campaigns/:id GET handler uses a wildcard regex /^\/campaigns\/([^/]+)$/
which captures "templates" as a fake campaign id and returns 404 before the
fixed /campaigns/templates routes get a chance to match.
Reorder the handle() chain so the fixed paths (/templates, /webhook) come
first, then the wildcard :id routes. Add a comment block calling out the
ordering requirement so future endpoints don't reintroduce the bug.
Verified live: GET /campaigns/templates returns the editable list,
GET /campaigns/templates/gift-email-fr still returns the HTML.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- lib/campaigns.js (new): full backend for the gift campaign flow.
• Two CSV parsers: parseMapCsv handles the pipe-delimited legacy export
with title preamble; parseGiftbitCsv auto-detects the URL column.
• Multi-strategy customer match against ERPNext: email → phone → civic
+ postal_code on Service Location. Returns confidence score (1.0 /
0.9 / 0.8) and match method. Addresses the 25%-match limitation of
the legacy_delivery_id approach by fanning out to address-based
lookup when email/phone miss.
• Storage: JSON files at data/campaigns/<id>.json with embedded
recipients array. Counters auto-recomputed from recipient statuses
on every save (single source of truth).
• Async send worker: setImmediate fire-and-forget loop, throttle
configurable, broadcasts recipient-update events over SSE topic
campaign:<id> for live UI progress.
• Mailjet webhook handler at POST /campaigns/webhook: matches events
to recipients via X-MJ-CustomID = "<campaign-id>:<recipient-index>"
for O(1) lookup, falls back to MessageID scan if CustomID absent.
• Template CRUD endpoints (GET/PUT /campaigns/templates/:name) with
automatic timestamped backups before overwrite. Path-traversal
guarded by an allow-list (only gift-email-fr editable).
• Mustache section renderer ({{#var}}...{{/var}}) shared with the CLI.
- lib/email.js: accept opts.from override (campaign sender differs from
default MAIL_FROM) and opts.headers passthrough (needed for the
X-MJ-CustomID header that drives webhook → recipient correlation).
Return the nodemailer info object on success instead of a bare bool so
callers can capture info.messageId — legacy truthy checks still work.
- server.js: register /campaigns/* route on the hub router.
- templates/gift-email-fr.html: bundled copy of the campaign template
inside the hub so it's deployable without scripts/ on the path. Kept
in sync manually with scripts/campaigns/templates/gift-email-fr.html.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>