Commit Graph

6 Commits

Author SHA1 Message Date
louispaulb
d897bcedb4 feat(campaigns): auto-clean first/last names (QC accents + compound split)
The Map CSV migrated from the legacy ERP carries names with two common
defects: missing French accents (Stephane, Andre, Frederic), and
compound first names that were typed without a separator (Marcandre,
Mariejosee, Jeanphilippe). Sending an email "Bonjour stephane," instead
of "Bonjour Stéphane," reads as sloppy automation. Fix both at parse
time so the user sees the corrected names in Step 2 and can override
inline if the auto-cleaner got it wrong.

Backend (lib/campaigns.js):

- FR_NAME_FIXES — 100+ entry dictionary mapping lowercase no-accent
  Québec first names to their canonical accented form (André, Stéphane,
  Frédéric, Geneviève, Hélène, Joséée, etc.). Sourced from MIQ baby
  names + older-generation curation.

- COMPOUND_PARTS — list of common name parts (jean, marie, anne, marc,
  philippe, françois, etc.) that combine into QC compound first names.
  When two parts appear concatenated with no separator, the cleaner
  splits and hyphenates them. Example: "Marcandre" → ["marc","andre"]
  → "Marc-André" (dictionary then applies accent).

- titleCaseToken — proper Title Case respecting apostrophes (O'Brien,
  L'Heureux) and hyphens (Marie-Ève). Uses \p{L} Unicode class so it
  works on accented chars correctly.

- cleanName(raw) — full pipeline: trim → Title Case → dictionary
  lookup per word → compound split fallback. Applied to firstname AND
  lastname in parseMapCsv.

- nameWarning(name) — heuristic flag for cases the cleaner couldn't
  confidently handle: digit in name, single letter, abnormally long
  without separator (likely two stuck names not in COMPOUND_PARTS).
  Returns a short FR description for the UI tooltip.

- parseMapCsv now returns firstname/lastname (cleaned) + firstname_raw/
  lastname_raw (original from CSV) + cleaned_changed bool + name_warnings
  per recipient. UI uses these to show before/after + flags.

UI (CampaignNewPage Step 2):

- New counter card "Noms à vérifier" — count of recipients with at least
  one nameWarning. Only renders if > 0.

- Info banner above the recipients table:
  "X nom(s) auto-corrigés (...)  Y nom(s) suspects (...)"

- Per-row icons in the firstname + lastname columns:
  • ⚠ amber WARNING — cleaner flagged this name as suspicious
    (tooltip shows the reason: "deux prénoms collés", "contient un
    chiffre", etc.)
  •  green AUTO_FIX_HIGH — auto-cleaner changed something at parse
    time (tooltip shows the original raw value)
  Both icons are tooltip-only — no action required.

- Click any name cell → q-popup-edit opens an inline input. Type the
  correction, Enter saves. ESC cancels. This is the manual override
  path for any name the auto-cleaner mishandled.

Tests (manual via end-to-end smoke against prod):
  STEPHANE TREMBLAY     → Stéphane Tremblay     ✓ accent + Title Case
  marie tremblay        → Marie Tremblay        ✓ Title Case only
  Marcandre Boileau     → Marc-André Boileau    ✓ compound + accent
  Jean Francois Lebrun  → Jean François Lebrun  ✓ accent only
  Mariejosee Lapierre   → Marie-Josée Lapierre  ✓ compound + double accent
  Andre LAPRISE         → André Laprise         ✓ both fixed
  Helene St-Pierre      → Hélène St-Pierre      ✓ accent, hyphen preserved
  Frederic O'Brien      → Frédéric O'Brien      ✓ accent, apostrophe preserved

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:33:10 -04:00
louispaulb
d6096fe1f8 feat(campaigns): apply real TARGO brand + auto-route FR/EN by Customer.language
Brand audit against the official guide (Feb 2026 v1.0) caught several
inconsistencies in the email template:

- Wrong primary green: was #019547, should be #00C853 (Targo Green from
  brand palette). Globally replaced.
- Wrong gradient: was #019547→#06a04d, should be 135deg #00C853→#005026
  (the official Gradient Targo from the brand). Now using Outlook-safe
  background-image + bgcolor fallback for solid green on Outlook desktop.
- Wrong contact info: facturation@targointernet.com / 514 242-1500 →
  support@targo.ca / 514 448-0773 / 1 855 888-2746 (per §11 of guide).
- Wrong website: targointernet.com + gigafibre.ca → www.targo.ca.
- Missing slogan + green dot: footer now ends with the trademark
  tagline "Services de confiance, tout-en-un, près de chez vous." with
  the obligatory green period (always FR — it's the trademark, not a
  marketing line, so stays untranslated in EN template too).
- Missing brand fonts: added Space Grotesk (display) + Plus Jakarta
  Sans (body) via Google Fonts. Wrapped in MSO conditional comments so
  Outlook desktop skips the request and falls back to Helvetica via
  the explicit font-family stack on every element.
- Wrong body bg / text colors: now #F5FAF7 (Muted) / #1B2E24
  (Foreground) per brand semantic palette.
- Wrong info-pill bg: was #f3f4f3 → #F5FAF7 (Muted).
- Added official dark footer band #1C1E26 (Targo Dark) with white
  inverted wordmark, slogan, address, copyright.

Multilang routing (FR/EN):

- lib/campaigns.js matchCustomer now fetches Customer.language
  (14k FR / 1k EN distribution confirmed on prod). Default 'fr' for
  unmatched contacts.
- New templateForLanguage(lang) helper picks gift-email-<lang>.html,
  falls back to FR. Resolves 'fr-CA' → 'fr' etc.
- sendCampaignAsync pre-loads templates per recipient with an in-memory
  cache to avoid re-reading from disk on every send.
- gift-email-en.html created — English translation of the full FR
  template, keeping the slogan in French (it's the trademark tagline).
- year variable now injected (replaces hardcoded © year).

UI (CampaignNewPage):

- New "Langue" column in the Step 2 recipient table. Shows a clickable
  chip (FR primary green / EN blue-grey) that toggles language inline,
  so a campaign manager can override the ERPNext-resolved language
  per recipient.
- Step 3 recap now shows "Répartition par langue: 145 × FR, 12 × EN"
  before confirming the send.

Spell-check:

- TemplateEditorPage HTML mode now has spellcheck="true" + dynamic
  lang attribute on the textarea, picked from the template name suffix
  (gift-email-fr → fr, gift-email-en → en). Browser's native dictionary
  flags typos in real time. AI-grade rewrites deferred to the future
  /campaigns/ai/rewrite endpoint discussed previously.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:50:56 -04:00
louispaulb
8d9e190c21 feat(ops/campaigns): explicit contact↔shortlink pairing review before approve
Step 2 of the new-campaign wizard previously dropped unpaired contacts
silently (Math.min(contacts, gifts) iteration) — if you uploaded 5
contacts and 3 gift links, you got 3 recipients in the table with no
visible signal that 2 contacts were left out. Step 1 only showed
"contacts skipped: N" in a small banner, easy to miss.

Surface the imbalance explicitly so the user can decide before sending:

Backend (POST /campaigns/parse):
- Return unpaired_contacts[] and unused_gifts[] arrays (with row_index
  for source-CSV cross-reference), in addition to the existing
  recipients[]. Old leftover_gifts / leftover_contacts counters kept
  for backward compat.

UI (CampaignNewPage Step 2):
- New columns in the recipients table:
  • # (row index from the source CSVs)
  • Lien-cadeau (truncated shortlink, clickable to verify)
  These let the user eyeball the contact↔link pairing line by line.
- New counter strip:
  Paires / À envoyer / Client lié / Sans client / Sans lien / Liens surplus
- "Sans lien" and "Liens surplus" counters appear only when relevant.
- Explicit warning banner explaining what unpaired/unused means
  (acquire more links and re-upload, or proceed knowing N won't get).
- Expansion panel listing each unpaired contact with their row_index +
  details, so the user can verify which specific contacts will be
  excluded before approving.
- Expansion panel listing each unused gift URL (extra capacity).
- "Approuver" button now shows the exact send count: "Approuver — N à
  envoyer". Disabled when 0. Step 3 recap also reflects sendableCount.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:31:44 -04:00
louispaulb
ff629a6a85 feat(campaigns): support Giftbit Link Order CSV + add blank-canvas editor mode
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>
2026-05-21 20:14:29 -04:00
louispaulb
0f78fbe27e fix(hub/campaigns): move /templates routes above the /:id wildcard
The /campaigns/:id GET handler uses a wildcard regex /^\/campaigns\/([^/]+)$/
which captures "templates" as a fake campaign id and returns 404 before the
fixed /campaigns/templates routes get a chance to match.

Reorder the handle() chain so the fixed paths (/templates, /webhook) come
first, then the wildcard :id routes. Add a comment block calling out the
ordering requirement so future endpoints don't reintroduce the bug.

Verified live: GET /campaigns/templates returns the editable list,
GET /campaigns/templates/gift-email-fr still returns the HTML.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:15:04 -04:00
louispaulb
5d763f12ff feat(hub): gift-campaign module — CSV parse, customer match, async send + webhook
- 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>
2026-05-21 19:07:40 -04:00