Commit Graph

73 Commits

Author SHA1 Message Date
louispaulb
8410464a22 feat(campaigns/reminder): cascade clicks to parent + family banner
A reminder campaign is a deep-copy of its parent's non-clicked
recipients with NEW gift_tokens. Clicks on the reminder were flagging
the CHILD recipient's gift_link_clicked but the parent campaign's
counters never updated — operators had to check two campaigns to see
the cumulative click rate.

Hub:
- New cascadeClickToParent() helper — when a recipient with
  parent_campaign_id is flagged as gift_link_clicked, mirror the flag
  + timestamp onto parent.recipients[parent_row_index] and broadcast
  a recipient-update SSE event so the parent's open page refreshes
  live. Adds a gift_clicked_via_reminder breadcrumb (the child
  campaign id) so the parent UI can show "↩ via la relance XXX".
  Idempotent — already-clicked rows are no-op.
- Three cascade call sites: applyWebhookEvent fast path (CustomID),
  applyWebhookEvent fallback (msgId scan), handleGiftRedirect wrapper.
- handleGiftRedirect also now sets gift_link_clicked=true on first
  successful redirect (Mailjet webhook can lag or drop; the wrapper
  redirect is the most reliable click signal we have).
- GET /campaigns/:id now attaches a "reminders" array with summary
  counters for every reminder child of the campaign.

Ops UI:
- "Cette campagne est une relance" banner on child detail pages with
  a back-link to the parent.
- "N relance(s) envoyée(s)" banner on parent detail pages with
  clickable chips showing each child's gift_clicked/total ratio.
- Recipient table: 🔁 icon next to the gift-click indicator when the
  click came via reminder, plus a "↩ via la relance XXX" line in the
  tooltip so the operator can trace the engagement channel.

One-time backfill applied on prod to mirror clicks that happened
between reminder send and this deploy (1 click cascaded —
cmp-20260522-2d4605 gift_clicked 27 → 28).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 15:24:52 -04:00
louispaulb
e7b937e2a3 style(campaigns/reminder): add "pour te remercier" / "as a thank-you"
Surfaces the WHY of the gift in the reminder body. The original
campaign was sent ~10 days before the reminder fires — recipients
may have forgotten the loyalty/gratitude context, leading to a
"what is this?" reaction when they see the reminder cold.

Adding two words ("pour te remercier" / "as a thank-you") cheaply
reconnects with the original messaging and reinforces TARGO's
relationship framing.

FR: "La carte-cadeau qu'on t'a envoyée pour te remercier peut
     s'utiliser chez des centaines de marques canadiennes..."

EN: "The gift card we sent you as a thank-you can be redeemed at
     hundreds of Canadian brands..."

Greeting kept as "Petit rappel pour {{firstname}}," — first-name
personalization beats generic "toi" on engagement metrics, and the
firstname auto-clean covers ~99% of recipients.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 15:09:41 -04:00
louispaulb
ddedd60320 fix(campaigns/expiry): format dates in America/Montreal, not container UTC
The targo-hub container runs with TZ=UTC (no override set). Calls to
toLocaleDateString without an explicit timeZone option were rendering
dates in UTC, which meant a wrapper expiring at 23:59 EDT (= 03:59
UTC next day) showed "22 juin 2026" to the recipient instead of the
intended "21 juin".

All 4 date-formatting sites in lib/campaigns.js now pass
timeZone: 'America/Montreal' explicitly:
- worker (sendCampaignAsync) — main send path
- /campaigns/:id/recipients/:i/view — web fallback render
- POST /templates/:name/test-send sample defaults
- POST /templates/:name/preview sample defaults

Verified on prod: stored UTC "2026-06-22T03:59:59Z" now formats
"21 juin 2026" / "June 21, 2026" with the timeZone option, matching
the operator's intent ("expiration en fin de journée le 21 juin EDT").

Also re-patched the relance draft cmp-20260601-f857cd-rem from
2026-06-21T23:59:59Z (= 19:59 EDT, the early-evening cutoff) to
2026-06-22T03:59:59Z (= 23:59:59 EDT, true end of day). Bonus: this
aligns with the original campaign's recipients which expire around
~11:57-12:04 EDT on June 21, so the reminder always works at least
as long as the original — never the inverse confusion.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 14:58:54 -04:00
louispaulb
c55f75739f fix(hub/cors): allow PATCH in preflight Access-Control-Allow-Methods
Browser CORS preflight (OPTIONS) for PATCH /campaigns/:id was
rejected because PATCH wasn't listed in Access-Control-Allow-Methods.
The browser surfaced this as a generic "Load failed" on the
"Enregistrer" button of the edit-params dialog. curl bypasses CORS
so backend testing missed it.

The header now includes PATCH alongside GET/POST/PUT/DELETE/OPTIONS.
Verified live: OPTIONS preflight now returns the full method list.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 14:49:11 -04:00
louispaulb
89057d0166 feat(campaigns/expiry): date picker for explicit cutoff
Operator can now choose an exact date for the wrapper expiry (e.g.
"valid until June 15") instead of computing days from today. Useful
when communicating a specific deadline to recipients.

Worker resolution order:
  1. params.gift_expires_at (full ISO datetime, set by the date picker)
     — all recipients of this campaign get THIS exact date, regardless
     of when the worker fires the send.
  2. Fallback: now() + gift_expiry_days (relative deadline, shifts
     forward by queue lag).

UI in both wizard (new campaign) and edit-params dialog (draft):
- Date picker at the top with cursor-pointer event icon + clear (x)
- Preset toggle (15/30/60/90/180/Custom days) below — auto-disabled
  when explicit date is set so the operator picks ONE mode
- Indicator "≈ N jours à partir d'aujourd'hui" when explicit date is
  active so the operator sees both representations

UI carries the picker value as YYYY-MM-DD (gift_expires_at_display);
launchSend / saveEditParams translate to ISO YYYY-MM-DDT23:59:59Z
before PATCH/POST. Anchoring at end-of-day local means "until June 15"
stays valid through all of June 15, not just the start.

dateAfterToday validator blocks past dates in the picker.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 14:45:14 -04:00
louispaulb
31562f62bf style(campaigns/templates): replace em-dashes with periods/commas
Some readers (and several modern style guides) read em-dashes as
"AI-written" feel — the user preferred a mix of period (for full
clauses) and comma (for asides) to keep the copy conversational
without the long pause em-dashes impose.

Period when both sides are independent clauses:
- about fiber. They're about people too. (EN main + reminder)
- on Giftbit. Just click your X. (EN main + reminder)
- pas manqué. La carte-cadeau qu'on t'a envoyée… (FR reminder)
- didn't miss it. The gift card… (EN reminder)

Comma when the second half is an aside or starts with "and":
- something special, for a limited time. (EN main)
- right next door, and we genuinely love… (EN main)
- aucun souci, pas besoin… (FR reminder)
- no worries, no need to reply… (EN reminder)

gift-email-fr unchanged — its user-visible text never had em-dashes
(the 3 detected were inside HTML comments).

No hub restart needed: the send worker reads templates fresh from
disk on every campaign run, so the new copy applies on the very next
"Lancer l'envoi" click.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:26:59 -04:00
louispaulb
73c42d6997 fix(campaigns/reminder): softer tone + render expiry in tests
The reminder copy read as pushy on test sends ("Hâte-toi! ... Tu n'as
encore rien fait, et le délai approche"). Toned down to factual and
friendly: state availability + offer the no-pressure path.

FR before / after:
   Hâte-toi! Ton cadeau de 60 $ expire le ___.       (red bold)
  → 🎁 Ton cadeau de 60 $ reste disponible jusqu'au 1 juillet 2026.
                                                       (brand dark green)

  Tu n'as encore rien fait, et le délai approche. Si tu n'utilises
  pas ton cadeau d'ici là, il ne pourra plus être réclamé.
  → On voulait juste s'assurer que tu ne l'as pas manqué — la carte-
  cadeau qu'on t'a envoyée peut s'utiliser chez des centaines de
  marques canadiennes, en quelques clics.
  Si tu préfères ne pas l'utiliser, aucun souci — pas besoin de
  répondre à ce courriel.

EN copy mirrored.

Also: {{expires_at_date}} was rendering empty in test sends and
previews because neither the test-send endpoint, the preview
endpoint, nor the editor's testSendForm.vars seeded it. Three fixes:
- Hub preview endpoint: compute now+30d as default sample date.
- Hub test-send endpoint: same default + expose view_url='' so the
  Mustache section block collapses cleanly in internal tests.
- Editor test-send dialog: pre-fill expires_at_date (and expires_in_
  days) with the same now+30d value, plus expose both fields as
  editable inputs so the operator can override per-test.

Verified live on prod: the preview endpoint with no vars now renders
"Ton cadeau de 60 $ reste disponible jusqu'au 1 juillet 2026."

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 11:55:20 -04:00
louispaulb
f414975b00 feat(campaigns): reminder campaign for non-clickers
Adds a "Créer une relance" button on the campaign detail page that
clones the parent campaign into a new draft, targeting only the
recipients who haven't clicked the Giftbit gift link yet.

Backend (POST /campaigns/:id/reminder):
- Filters parent recipients: status sent/opened, not excluded, not
  revoked, wrapper not yet expired, has a gift_url.
- Builds a fresh recipients array — same gift_url (Giftbit shortlink),
  same name/email/language/amount, but cleared gift_token so the worker
  generates a brand-new wrapper at send time. Each campaign owns its
  own click metrics.
- New campaign starts as 'draft' so the operator can review, tweak
  subject/template, and click "Lancer l'envoi" when ready.
- Tracks parent_campaign_id + parent_row_index on each reminder row
  for traceability in CSV reports and debugging.

Templates (gift-email-reminder-fr / gift-email-reminder-en):
- Header swap: "Petit rappel pour {firstname}" / "Quick reminder, X"
- Bold orange urgency line: " Hâte-toi! Ton cadeau de X expire le Y"
  using the existing {{expires_at_date}} and {{amount}} merge vars
- Body shortened — drops the manifesto, focuses on "you have a gift,
  redeem before it's gone"
- Same CTA button + prorata disclaimer + signature + footer as the
  main templates so brand stays consistent.

UI:
- Button visible when campaign is sending/completed AND it's not
  itself a reminder AND there's ≥ 1 eligible non-clicker.
- Confirmation dialog spells out the mechanics: same Giftbit URLs,
  new wrapper tokens, reminder template, sample expiry date pulled
  from the campaign's first recipient with a gift_expires_at.
- On OK, redirects to the new campaign's detail page.

Click stats on the existing campaign (cmp-20260522-2d4605) verified
intact before+after deploy (109 opens, 15 generic clicks, 27 gift CTA
clicks) — saveCampaign persists per-event so the hub restart was a
no-op for accumulated data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 11:43:35 -04:00
louispaulb
6577bb79bc feat(campaigns/send): real SMTP error + auto-retry + one-click Renvoyer
The send worker used to write "SMTP send returned false (see hub logs)"
on every failure, forcing the operator to SSH into the box to find the
actual cause. Now we capture the real reason and surface it in the UI.

Three changes:

1. lib/email.js exposes getLastError() — a side-channel for the most
   recent nodemailer error message, cleared at the start of every
   sendEmail call. Legacy "if (await sendEmail(...))" callers stay on
   the false-return contract; only the campaign worker reads the
   side-channel for detailed error capture.

2. The worker now retries each recipient up to 3 times (initial +
   2 retries with 2s/5s backoff). Most "Unexpected socket close"-style
   transient Mailjet errors recover on the second attempt. We observed
   exactly this case for Myriam Bergevin in cmp-20260522-2d4605 — a
   single socket close interrupted 1 of 202 sends; auto-retry would
   have caught it. retry_count is now stored on the recipient.

3. POST /campaigns/:id/recipients/:row/retry resets a single failed
   row back to pending and re-fires the worker. Surfaced in the
   detail-page table as a small 🔁 button next to the error text on
   any row with status=failed. Useful when auto-retry exhausted its
   3 attempts on a one-off transient.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 13:29:25 -04:00
louispaulb
d5ee57acf2 feat(campaigns/wizard): inspectable dropped-row list with one-click recovery
parseMapCsv now collects the actual rows it drops (capped at 200),
each with its skip reason and the raw source-CSV columns (full_name,
email, phone, address, postal). Returned alongside the existing
counters as skipped_rows on the parse response.

Wizard Step 2 adds an "N ligne(s) du Map CSV non importée(s)"
expansion below the imbalance banner, showing:
  Ligne # | Raison | Nom au CSV | Email au CSV | Adresse | CP | →

The action column has a "Ajouter manuellement" button on rows that
have an email (duplicate, multi_skip) — clicking opens the manual-
add dialog pre-filled from the dropped row, so the operator can
recover the contact in two clicks. no_email rows can't be recovered
that way and don't get the button.

The source_row index is the Excel-relative line number (counting the
header) so the operator can cross-reference the actual file.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 11:55:48 -04:00
louispaulb
5c55087198 fix(campaigns/parse): keep no-name rows + surface skip breakdown
Map CSV rows that had a valid email but no name in the source column
were silently dropped at parsing — that's why a campaign would end up
with N unpaired Giftbit shortlinks for N "missing" contacts that
weren't actually missing, just nameless.

The send worker already handles a missing firstname by substituting
"cher client" / "dear customer", so dropping the row was wasteful.
Now we keep the contact and surface a name_warning on the row so the
operator can either edit the firstname in Step 2 or accept the
default.

Also added counters for previously-silent skip paths:
- duplicate: row's email was already seen above (1 gift / household
  consolidation, depending on the multi setting)
- multi_skip: couple skipped because multi='skip' was selected

Wizard Step 2 imbalance banner now ventilates the skip breakdown so
the operator understands exactly where the N "missing" contacts went:

  Ventilation des contacts droppés au parsing du Map CSV (sur 213
  lignes brutes) : 8 sans email valide · 5 emails en double · 0
  couples ignorés · 3 sans nom (gardés, utilisent "cher client" à
  l'envoi)

Unrelated reassurance on the question that triggered this: language
fallback to French is already in place (matchCustomer returns
language:'fr' on miss, worker reads (r.language || 'fr')) so any
unmatched recipient gets the FR template, never an English one by
default.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 11:43:22 -04:00
louispaulb
f6d06d9b34 fix(campaigns/match): handle multi-email + dupe SLs + missing postal
Three independent bugs surfaced while debugging why Alexandre Duval
showed as "non lié" in a campaign:

1. ERPNext Customer.email_id can hold multiple addresses joined by
   ';' or ',' (211 records inherited from Legacy migration). The
   exact-match filter missed them. Now LIKE-searches a window then
   validates locally by splitting on ; , or whitespace.

2. Service Locations have duplicates at the same address — the same
   "7 Rue des Merles" exists 3 times, linked to 3 different customers
   (legacy migration artifact). The civic+postal strategy was taking
   the first hit which could be the wrong household. Added name-aware
   disambiguation: when the recipient has a name, walk the candidates
   and pick the one whose linked Customer name plausibly matches.

3. New 4th matching strategy "name+civic" — kicks in when the CSV
   row has no postal_code (most common Map export failure mode). Does
   a street-word filtered SL search and accepts only candidates whose
   Customer name plausibly matches. Confidence 0.65 (vs 0.85 for
   civic+postal).

Also: SQL filter for both civic+postal and name+civic now includes a
street-word LIKE constraint so the result set isn't dominated by
unrelated "7 ..." addresses, bumped limits to 50/100. The SL
denormalized customer_name field is often empty post-import — we now
fall back to a Customer lookup for the name check.

Verified end-to-end against live ERPNext: Alexandre Duval at
7 Rue des Merles now matches correctly via email (multi-value field),
via civic+postal (despite 3 dupe SLs), and via name+civic (no postal).
Gaëtan David at the same address also matches correctly without
collision.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 11:36:51 -04:00
louispaulb
85ad66f103 feat(campaigns): one-click Giftbit admin lookup per recipient
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/4kpZMApLK4Bhttps://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>
2026-05-22 10:58:33 -04:00
louispaulb
feeae6eb40 feat(campaigns/templates): visible wrapper-expiry date in the email
Two new template variables are auto-derived from r.gift_expires_at at
render time (separately by the worker and the /view fallback to keep
them consistent):

  {{expires_at_date}}  locale-formatted FR/EN long date — "21 août 2026"
                       / "August 21, 2026". Empty when no wrapper token.
  {{expires_in_days}}  remaining days as string (rounded up). Useful
                       for tight deadlines where a date is too distant
                       to convey urgency.

Templates: a small centered badge appears between the CTA button and
the prorata disclaimer, wrapped in a Mustache section so it disappears
cleanly on campaigns that pre-date the wrapper feature.

   Cadeau valide jusqu'au <strong>21 août 2026</strong>
   Gift valid until <strong>August 21, 2026</strong>

Editor merge-tag panel updated so authors can drop these into custom
copy without remembering the exact variable names. The legacy
{{expiry}} field stays — it's still the right tool for promotion-end
dates that don't track the gift link's own deadline.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 10:47:58 -04:00
louispaulb
d529019106 feat(campaigns): gifts inventory page + expiry presets
Wizard: gift_expiry_days now lives behind a preset toggle
(15/30/60/90/180 + Custom) instead of a naked number input. Operator
clicks a chip; the value flows back into the existing campaign param.

Inventory page (/campaigns/gifts):
- Cross-campaign view of every wrapper token with status taxonomy
  (active / redeemed / expired / revoked / pending). Each card on
  the counters strip is a click-to-filter shortcut.
- "Réassignables" highlighted in amber when > 0 — these are gifts
  whose wrapper expired or was revoked but the Giftbit URL is still
  unredeemed, ready for a fresh recipient.
- Search across name/email/url/token; per-status and per-campaign
  filter dropdowns.
- One-click copy on the Giftbit URL with a tailored toast that walks
  the operator through the reassignment workflow (paste into manual-
  add dialog of a new campaign).
- Revoke action with confirmation; explicit about what survives
  (the Giftbit URL stays valid on their side) vs what changes (our
  wrapper stops redirecting).

Backend:
- GET /campaigns/gifts flattens every recipient with a gift across
  every campaign — single-shot, no pagination yet (we're under 10k
  gifts total).
- POST /campaigns/:id/recipients/:row/revoke sets gift_revoked=true
  and broadcasts the recipient-update SSE event.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 10:21:05 -04:00
louispaulb
c0ca5feb6f feat(campaigns): gift redirect wrapper — own expiry + reusable links
Each campaign recipient now gets a short opaque token (10 base64url
chars, ~60 bits entropy). The email contains

  https://msg.gigafibre.ca/g/<token>

which 302-redirects to the underlying Giftbit shortlink — but ONLY if
the recipient hasn't passed our own expires_at and we haven't revoked
the token. This gives us two new operational capabilities:

1. End-date control independent of Giftbit. The wizard now has a
   "Expiration interne (jours)" field (default 90) that sets our
   own deadline. Useful when the Giftbit gift is valid 12 months
   but the campaign offer should expire in 30 days.

2. Reuse of unredeemed gifts. After our expiry, the old wrapper
   stops working but the Giftbit URL is still valid on their side.
   Pasting that same gift_url into a new campaign (via the manual-add
   dialog) generates a NEW token pointing to the same Giftbit gift —
   the original recipient's old wrapper URL says "expired", the new
   recipient gets a fresh window.

Per-recipient new fields:
- gift_token            short ID used in the wrapper URL
- gift_expires_at       ISO timestamp of our cutoff
- gift_revoked          manual kill-switch (false by default)
- gift_redirected_count clicks that successfully reached Giftbit
- gift_first_redirected_at  first successful redirect timestamp

Routing:
- GET /g/:token  — public, validates and 302s (or expired-page)
- Mailjet click event handler updated to recognise wrapper URLs
  alongside legacy gft.link/giftbit.com URLs.
- /view (browser fallback for in-email rendering) also wraps the
  gift link so expiry/revoke is honoured consistently.

Bootstrap rebuilds the in-memory token→recipient index by scanning
all campaign JSONs on startup — no separate index file to keep in
sync.

CSV report adds gift_token, gift_expires_at, gift_revoked,
gift_redirected_count, gift_first_redirected_at.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 10:15:43 -04:00
louispaulb
5330fecf43 feat(campaigns/wizard): per-language template override
Two new dropdowns in Step 1 ("Template français" / "Template anglais")
populated from /campaigns/templates filtered by suffix (-fr / -en).
Selection is stored on campaign.params.template_fr / .template_en
and the worker resolves the actual path via a new resolveTemplatePath
helper:

  1. params.template_<lang>  (per-lang override, set here)
  2. params.template_path    (legacy single-template campaign override)
  3. templateForLanguage()    (default gift-email-<lang>.html)

Defensive name regex inside resolveTemplatePath blocks path traversal —
operator can pick any *-fr / *-en template that exists, nothing else.

The Step 3 summary list now shows which template will actually ship
per language so the operator can sanity-check before launch.

Use cases: seasonal variants (gift-email-2026-summer-fr), A/B tests,
draft templates that aren't ready to be the default yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:46:58 -04:00
louispaulb
9450dd34db feat(campaigns): delete campaign from the list
DELETE /campaigns/:id removes the JSON from /opt/targo-hub/data/campaigns/.
The Giftbit shortlinks already issued for that campaign live on Giftbit's
side and are unaffected — this is purely about clearing internal tracking
records (typically test runs cluttering the list).

Refuses (409) while the send worker is active for that id so we never
yank the file out from under saveCampaign(). Defensive id regex
(in campaignPath) blocks path-traversal attempts before unlink runs.

UI: red trash icon on each row, disabled while status=sending.
Confirmation dialog spells out what survives the deletion (Giftbit
links) vs what's lost (tracking, opens/clicks, CSV report) so the
operator isn't surprised.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:33:44 -04:00
louispaulb
4babb403e8 feat(campaigns): "View in browser" web fallback for failed inbox renders
GET /campaigns/:id/recipients/:i/view re-renders the campaign with the
same vars the worker used at send time — same template, same per-row
amount override, same language pick. Useful when the recipient's mail
client butchers the layout: image-blocking, antique Outlook, niche
third-party apps, accessibility tools.

Templates: Mustache section {{#view_url}}…{{/view_url}} guards a tiny
gray link above the header logo (11px, #94a3b8). The section collapses
to nothing when view_url is empty, so:
  - the /view page itself doesn't show the link (you're already there)
  - wizard previews / test-sends don't show it (no real campaign id)

worker passes view_url = HUB_PUBLIC_URL + /campaigns/<id>/recipients/<i>/view
using the existing cfg.HUB_PUBLIC_URL setting (defaults msg.gigafibre.ca).

Security: campaign-id is a 21-char nanoid (≈10²¹ space). Same level of
exposure as the Giftbit shortlink itself. X-Robots-Tag: noindex on the
response so the URLs don't end up on search engines.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:30:38 -04:00
louispaulb
9fb6fab88e feat(campaigns): distinguish gift-CTA click from generic email click
Mailjet's click event includes the actual URL the recipient clicked. We
previously bumped every click — CTA button, mailto support, footer link —
to status='clicked' indiscriminately. Now we additionally flag clicks on
the Giftbit shortlink (matched by r.gift_url prefix, fallback to gft.link
or giftbit.com host) as the high-signal "gift_link_clicked" event.

Adds:
- recipient.gift_link_clicked (bool) + gift_clicked_at (ISO timestamp),
  set on first matching click; later non-gift clicks don't unset
- counters.gift_clicked aggregated alongside existing status counters
- "Cadeau cliqué" counter card on detail page (deep-purple, redeem icon)
- 🎁 redeem icon next to status chip when the recipient engaged
- CSV report: new gift_link_clicked + gift_clicked_at columns

Why this matters: "opened" is noisy (Apple Mail Privacy Protection, image
proxies prefetch). A click on the CTA is the only reliable indicator
that the offer landed and the recipient is engaging.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:22:12 -04:00
louispaulb
2bc9715485 feat(campaigns): CSV report, manual recipients, template polish
Ops UI
- CampaignDetailPage: "CSV" button — downloads per-recipient report
  (shortlinks, status, opened/clicked timestamps, mailjet UUID)
- CampaignNewPage: "Saisie manuelle (sans CSV)" on Step 1 and
  "Ajouter manuellement" on Step 2 — both open the same dialog with
  firstname / email / gift_url / city / postal_code / language /
  amount override. Indigo "manuel" chip in the recipients table.
- New "Ville" column shows city OR postal_code as fallback.

Hub
- GET /campaigns/:id/report.csv — RFC 4180 CSV with UTF-8 BOM so
  Excel auto-detects encoding. 20 columns including new "city".
- Worker honours per-recipient amount override:
  r.amount > derive from r.gift_value_cents > params.amount > "50 $".
  Fixes manual-add showing campaign default instead of typed value.
- Default subject "Un cadeau pour toi" (tutoyer).

Templates
- Order: Intro →  Option 1 → 🎁 marques → CTA → prorata → ⏭️ Option 2.
- New EN intro (manifesto): "Thank you for choosing local. Your
  support helps keep our community connected. / Because great
  connections aren't just about fiber — they're about people too."
- Amazon logo removed (incongruent with "achat local" framing).
- Body paragraphs: text-align justify (greeting/labels stay left).
- Support line: "N'hésite pas à nous écrire / Feel free to email us"
  + dash format 514-448-0773, drop "Support 7j/7" overpromise.
- Logo style fix: inline width:32px to beat Unlayer canvas CSS that
  was rendering brand pills full-width.

Ignore template converter .bak-*.json backups.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:09:07 -04:00
louispaulb
40a2e4e8f2 fix(campaigns/templates): drop 'sans engagement' claim from Option 2
The 'sans engagement ni carte-cadeau' wording in Option 2 was confusing
for customers with an existing multi-month commitment — implies their
subscription is commitment-free, which contradicts their actual contract.

Reworded to make zero claims about the customer's commitment status,
just describes what happens if they ignore the email:

FR:
  before — 'Ne rien faire. Ton abonnement mensuel se poursuit normalement,
            sans engagement ni carte-cadeau.'
  after  — 'Ne rien faire. Aucun changement à ton abonnement actuel.'

EN (Gemini copywriter version was different from earlier templates):
  before — 'Just kick back! Your monthly subscription will continue as
            usual, with no commitment and no gift card.'
  after  — 'Do nothing. No changes to your current subscription.'

Benefits:
  • No false claim about engagement status
  • Shorter, more direct
  • Still preserves the explicit consent UX (customer knows ignoring
    is a valid choice without consequence)
  • No mention of 'no gift card' — that's implicit from not clicking
    the CTA, doesn't need to be stated

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 08:09:39 -04:00
louispaulb
8df17c823a fix(campaigns/templates): inject inline logos into EN template too
The previous logo injection commit (d76a922) only matched the FR anchor —
EN was missed because Gemini's copywriter-mode translation rewrote
'at hundreds of brands' as 'to spend at hundreds of your favorite stores'.
Patched EN with the correct anchor + 'and more' caption.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 08:03:54 -04:00
louispaulb
d76a922777 feat(campaigns/templates): inline merchant logos via simple <img> sequence
The previous attempt (commit 3f72608) tried to inject a nested <table>
of logos, but the anchor selection logic looked for a </p> that didn't
exist — Unlayer renders the amount line inside a <div>, not <p>. As a
result the logos never made it into the templates.

This commit fixes it with a simpler approach: directly append the logo
images to the existing amount-text string. No table nesting, no anchor
hunting — just plain inline-block <img> tags right after the
"🎁 {{amount}} chez des centaines de marques" text.

Markup pattern (inserted right after the amount line, before the
closing </div>):

  <br><br>
  <span style="display:inline-block;">
    <img src="...amazon..." width="32" alt="Amazon" ...>
    <img src="...timhortons..." width="32" alt="Tim Hortons" ...>
    <img src="...walmart..." width="32" alt="Walmart" ...>
    <img src="...homedepot..." width="32" alt="Home Depot" ...>
    <img src="...iga..." width="32" alt="IGA" ...>
    <img src="...homehardware..." width="32" alt="Home Hardware" ...>
    <span style="font-size:13px;color:#64748B;">et plus</span>
  </span>

Each <img> uses display:inline-block + vertical-align:middle so they
sit on the same horizontal line. width=32 attribute set for Outlook;
height:auto in style preserves aspect ratio. margin-right:6px provides
spacing between logos. Caption ("et plus" / "and more") at the end.

Width math (inside 484px-wide pill): 6 × (32 + 6) = 228 px + caption
~50 px = 278 px. Fits with margin to spare.

EN translation auto-detected the equivalent anchor and inserted
"and more" instead of "et plus".

Live test-send verified for both FR + EN.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 08:00:44 -04:00
louispaulb
3f72608a2f feat(campaigns/templates): inline merchant logos + objective prorata phrasing
Two refinements per user feedback:

1. Objective/factual prorata disclaimer (shorter, conditions-of-service tone)
   FR:
     before — "Si tu annules avant {{commitment_months}} mois, tu rembourses
              seulement au prorata des mois restants."
     after  — "Annulation avant {{commitment_months}} mois : seulement à
              rembourser au prorata des mois restants."
   EN:
     before — "If you cancel before {{commitment_months}} months, you only
              refund the prorated amount for the remaining months."
     after  — "Cancellation before {{commitment_months}} months: only the
              prorated amount for the remaining months is refundable."

   The colon-prefixed structure ("X : Y") reads like a T&C bullet rather
   than a marketing sentence — clearer, less wordy, no subject pronoun.

2. Inline row of 6 merchant logos in the offer info pill
   Inserted between the "60 $ chez des centaines de marques" line and the
   "Instant activation" line. 6 most recognizable QC brands at 32px wide:
     Amazon · Tim Hortons · Walmart · Home Depot · IGA · Home Hardware
   Followed by "et plus" / "and more" caption.

   Uses the existing Mailjet-hosted brand logos (same URLs as the 4×3 grid
   in the older rich variant). 32px width fits comfortably on one line
   (~280px total in a 484px-wide pill). Email-safe single-row table layout
   with vertical-align middle, padding-right 8px for spacing.

   Visual effect: instant recognition for the reader — they see the brands
   they'd actually redeem at, without dropping the full 12-logo grid that
   bloated the previous design.

Applied to .html + .json (both FR + EN) via anchor-based injection:
finds the "🎁 {{amount}} chez/at hundreds of marques/brands" paragraph,
inserts the logo table immediately after its closing </p>. Both files
remain valid + the Unlayer editor will pick up the new table next load.

Verified live via test-send (35-37 KB output, recipient queue ok).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:58:55 -04:00
louispaulb
5327112717 fix(campaigns/templates): clearer prorata phrasing — "mois restants" / "remaining months"
Iterating on the prorata disclaimer per user feedback. The previous
version ("tu rembourses le prorata non utilisé (20 $/mois)") still
read ambiguously — "non utilisé" could mean "the portion you haven't
spent" which is conceptually confusing for a one-time gift card, and
the hardcoded "$20/month" tied the template to the specific
$60/3-month campaign.

New phrasing makes the math explicit: refund only for the months
you're NOT staying.

FR:
  before — "Si tu résilies avant {{commitment_months}} mois,
            tu rembourses le prorata non utilisé (20 $/mois)."
  after  — "Si tu annules avant {{commitment_months}} mois,
            tu rembourses seulement au prorata des mois restants."

EN:
  before — "If you cancel before {{commitment_months}} months,
            you refund the unused pro-rated amount ($20/month)."
  after  — "If you cancel before {{commitment_months}} months,
            you only refund the prorated amount for the remaining months."

Wins:
  • Subject ("tu" / "you") explicit — no ambiguity on who refunds
  • Logic clarified — refund == months NOT STAYED, not "unused
    portion of money" (which doesn't quite map to a one-time gift)
  • Generic over campaign params — no hardcoded "$20/month" so the
    template works at any gift amount + commitment combination
  • "annules" (more common in QC consumer-facing) instead of
    "résilies" (slightly more formal/legal-sounding)

Applied via direct find/replace on .html + .json (FR + EN). Live
test-send queued to confirm rendering.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:50:51 -04:00
louispaulb
f37b1d2803 fix(campaigns/templates): clarify who refunds whom for early cancellation
User feedback: the prorata disclaimer was semantically wrong — both FR
("le prorata du montant est remboursable") and EN ("we'll refund the
pro-rated amount") read as if TARGO would refund the customer, when
actually the customer needs to refund the unused portion of the gift
they received if they cancel within the commitment period.

Plus: add the explicit per-month rate ($20/month at $60 / 3 months) so
the customer knows exactly what they'd owe at any cancellation date.

FR:
  before — "🪂 En cas de départ avant {{commitment_months}} mois,
            le prorata du montant est remboursable."
  after  — "🪂 Si tu résilies avant {{commitment_months}} mois,
            tu rembourses le prorata non utilisé (20 $/mois)."

EN:
  before — "🪂 If you decide to leave before {{commitment_months}}
            months, we'll refund the pro-rated amount."
  after  — "🪂 If you cancel before {{commitment_months}} months,
            you refund the unused pro-rated amount ($20/month)."

Both changes:
  • Subject clarified: customer refunds, not TARGO
  • Added explicit per-month value for transparency
  • Kept warm tone (informal "tu" / "you")
  • Mustache {{commitment_months}} preserved

Applied directly to .html + .json via string substitution (preserves
the Unlayer design tree intact except for that one phrase). The
"$20/month" figure is hardcoded for the current $60/3-month campaign;
a future {{monthly_prorata}} computed variable would generalize but
isn't needed yet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:39:01 -04:00
louispaulb
2c47d3269e feat(campaigns/translate): switch from literal to copywriter-mode AI prompt
User feedback: previous prompt produced robotic word-for-word output that
lost the marketing impact. The system prompt was too restrictive on
preservation, suppressing Gemini's natural rephrasing ability.

Rewrote the system prompt with three structural changes:

1. FRAME shifted from "translator" to "senior marketing copywriter"
   The opening line now says "You are NOT translating words — you are
   rewriting marketing copy that lands the same way for a different
   audience." This unlocks idiomatic rephrasing, sentence reorganization,
   active/passive switching, and cultural metaphor adaptation.

2. FEW-SHOT EXAMPLES showing the desired style
   4 FR→EN pairs in the prompt itself, with explicit "NOT: <literal>"
   anti-examples to show what to avoid:
     • "loyauté envers l'achat local" → "keeping it local"
       (not "loyalty to local shopping")
     • "connexions stables et relations durables" →
       "steady connections — both the fiber kind and the human kind"
       (not "stable connections and lasting relationships")
     • "On est juste à côté" → "We're right next door"
     • "Avec l'arrivée de l'été" → "Summer's here"
   These ground Gemini in the brand voice with concrete examples.

3. TONE constraint explicit
   "Warm, conversational, slightly playful. Like a neighbor explaining
   something — never corporate, never stiff." Use of contractions
   ("we're", "you'll") encouraged.

Plus: temperature bumped from 0.2 → 0.7 so Gemini actually exercises
creative rephrasing instead of staying glued to source word order.

Structural preservation rules (HTML, Mustache vars, brand names, emojis,
URLs, technical values like "3.5 Gbit/s") kept as HARD CONSTRAINTS but
clearly separated from the creative freedom on text content.

Live re-translation of gift-email-fr → gift-email-en applied:
  • 51s response time (similar to literal version)
  • 35,934 → 36,067 bytes (slight expansion, normal for EN)
  • Output markers confirm idiomatic phrasing landed:
    "Thanks for keeping it local", "steady connections — both kinds",
    "right next door", "lending a hand", "Summer's here"
  • Mustache vars + brand names + HTML preserved (verified)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:29:16 -04:00
louispaulb
1b399f65eb feat(campaigns): AI template translator via Gemini Flash
New "Traduire (AI)" button in the template editor toolbar. One click
translates the current template's HTML to the opposite language
(detected from the -fr/-en suffix), writing the translated content as
the matching companion template.

Backend (lib/campaigns.js):
- New endpoint: POST /campaigns/templates/:name/translate-to/:targetName
- Reads source .html, calls lib/ai.js aiCall() with Gemini Flash
- System prompt enforces 7 strict preservation rules:
  1. Byte-preserve all HTML tags/attributes/styles/Outlook conditionals
  2. Don't translate Mustache {{vars}}
  3. Preserve URLs/emails/phones/hex colors/CSS/brand names (TARGO,
     Gigafibre, Giftbit, Amazon, IGA, Tim Hortons, etc.)
  4. Preserve emojis (🎁  🤝 🪂  ⏭️ )
  5. Keep the warm informal tone (tu in FR, you in EN)
  6. Translate only visible text inside elements (paragraphs, buttons,
     alt attributes, link text)
  7. Output full HTML doc only, no markdown wrapping
- temperature=0.2 for stable output, maxTokens=32768 to fit ~35 KB HTML
- Sanity validates output isn't truncated (>50% of source size)
- Strips defensive markdown fences if AI ignored rule 7
- Auto-backs up existing target before overwrite
- Regenerates Unlayer design JSON from the translated HTML so the
  editor can reload the translated template visually
- Requires { override: true } in body to overwrite existing target
  (409 Conflict otherwise — protects against accidental clobber)

API client (apps/ops/src/api/campaigns.js):
- translateTemplate(srcName, targetName, { override })

Frontend (TemplateEditorPage.vue):
- "Traduire (AI)" button (purple, icon=translate) in toolbar — disabled
  when current template has no -fr/-en suffix
- aiTranslateTargetName computed: detects source lang from suffix,
  flips to opposite (-fr → -en, -en → -fr)
- Confirmation dialog:
  • Shows source → target template names
  • Info banner explaining what's preserved (HTML, vars, brands, emojis)
  • Amber banner + toggle if target exists (must confirm override)
- On success: positive notification with byte counts +
  "Open" action button to jump to the translated template
- Refreshes templates list after translation so the new file appears
  in the selector dropdown

UX: replaces the previous manual translation workflow (where the user
or I had to maintain two parallel templates). One click now does the
whole round-trip. User reviews + adjusts wording in the EN editor if
the AI translation needs polish.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:21:45 -04:00
louispaulb
d716e69ef6 feat(campaigns/templates): mirror user's FR edits to EN + drop legacy .mjml
User edited gift-email-fr in the Unlayer editor with richer marketing
copy (loyalty thanks, brand manifesto, 3.5 Gbit/s upsell, helpful CTA).
Mirror those edits to the EN template via a one-shot translation script
so the bilingual pair stays in sync for the next campaign send.

Translation strategy: plain-string find/replace mapping with FR
phrases in longest-first order to avoid partial matches. Applied to
BOTH the rendered .html (what the recipient sees) AND the .json
(Unlayer design tree — so re-opening the EN editor preserves the
matching structure).

Mapping coverage:
  • Intro paragraphs (greeting, gift announcement, loyalty thanks,
    brand manifesto, speed upsell, "we're around the corner")
  • Offer info pill (amount, instant activation, commitment)
  • CTA button labels (Activer → Redeem, Choisir → Pick)
  • Prorata refund disclaimer
  • Option 2 "do nothing" text
  • Signature ("Merci de faire rouler" → "Thanks for helping...thrive")
  • Footer contact info + "Tous droits réservés" → "All rights reserved"
  • <html lang="fr"> → <html lang="en">

23/28 translation rules matched; the 5 unused ones were for legacy
phrasing not present in the user's latest save (e.g. the old "Tu
choisis local" line that was replaced by the current intro).

Also: drop the obsolete .mjml source files. Now that Unlayer is the
canonical editor, the MJML→HTML compile pipeline is no longer used
on save (Unlayer outputs HTML directly). The .mjml files were stale
copies from the previous MJML-based editor. Removed from disk on
prod and from git history; rollback via git revert if needed.

Verified live: GET /campaigns/templates/gift-email-en returns the
translated content (9 EN markers detected in HTML). Test-send to
louis@targo.ca queued via Mailjet for visual QA.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:07:05 -04:00
louispaulb
2f1ebae587 fix(hub): templates volume mount must be RW for editor saves
When the Unlayer editor calls PUT /campaigns/templates/:name to save a
design, the hub writes:
  • templates/<name>.html   (compiled email-safe HTML)
  • templates/<name>.json   (Unlayer design tree for editor restore)
  • templates/<name>.bak-<ts>.html  (backup of previous version)

All three need write access to /app/templates inside the container.
The mount was previously declared as :ro, which made these writes
fail with EROFS (read-only filesystem) once the editor was wired up.

Two changes:
  1. Local docker-compose.yml: add ./templates:/app/templates (without
     :ro) and ./uploads:/app/uploads (which was already RW on prod but
     missing from the committed file — local was out of sync).
  2. Prod docker-compose.yml: hot-patched via sed on prod to drop the
     :ro flag, then `docker compose down + up -d` to apply the mount
     change. PUT verified working (returns 200 with size + design_size).

The /app/lib, /app/server.js, /app/public, /app/package.json mounts stay
:ro since the hub never writes to those — keeping the read-only flag
there is defense-in-depth against compromised code overwriting itself.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:49:48 -04:00
louispaulb
73e4118901 feat(campaigns): create new templates from UI + enable Unlayer template library
Two improvements to the template editor:

1. "+ Nouveau" button + creation dialog
   Users can now create new templates from the editor UI without us
   re-deploying the hub. Click "Nouveau" next to the template selector,
   pick a name + prefix + starter (blank or copy from existing), submit.
   The hub PUTs the new template (existing endpoint, no new code needed
   on the backend — just relaxed validation).

   Form:
     • Type (prefix): gift-email / newsletter / transactional
     • Name suffix: lowercase letters/digits/dashes (e.g. summer-2026)
     • Starter: "Vide" or "Copier depuis <existing template>"

   On submit:
     • If starter != blank: GET source template's html + design
     • PUT new template name with that content
     • Refresh templates list + switch editor to the new one

2. Backend: replace hardcoded EDITABLE_TEMPLATES allow-list with
   regex-validated prefix matching + disk scan
   • EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-',
     'transactional-'] — bounds what categories users can create
   • TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ — prevents path traversal
   • isValidTemplateName() validates both regex + prefix membership
   • scanEditableTemplates() returns all matching .html/.mjml files
     currently on disk (excludes .bak-* and .legacy-* variants)
   • listEditableTemplates() now scans disk instead of a static list,
     so newly-created templates appear automatically in the dropdown

3. Enable Unlayer's built-in panels
   • templates: true — exposes Unlayer's template library (limited
     free-tier selection but ~10-20 starters available without a
     projectId)
   • stockImages: true — Unsplash search built into image picker
   • imageEditor: true — basic crop/resize on inserted images
   • undoRedo: true — history navigation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:39:26 -04:00
louispaulb
448e62177e feat(campaigns): convert existing HTML templates to Unlayer JSON designs
Solve the "editor starts blank" problem by writing a one-time converter
that wraps each compiled .html template into a minimal Unlayer design
JSON (one Custom HTML block containing the entire body content). On
next editor load, Unlayer reads .json and renders the template in the
canvas — instant visual fidelity without manual reconstruction.

Strategy choice: Unlayer's "Import HTML" is a Pro-only feature. Building
a real HTML→Unlayer-blocks parser is several days of work. The minimal-
viable conversion (1 row + 1 Custom HTML block) gets the user 90% there
immediately:

  • Canvas shows the template visually (Unlayer renders the HTML)
  • Variables ({{firstname}}, {{gift_url}}, etc.) preserved as text
  • User can edit the HTML directly via the block's side panel
  • User can incrementally REPLACE the HTML block with native Unlayer
    blocks (Text, Image, Button) for any section they want decomposed —
    on their own schedule, not blocking the campaign send

New file: services/targo-hub/scripts/convert-html-to-unlayer.js
  • CLI: node scripts/convert-html-to-unlayer.js <template-name>
  • Reads templates/<name>.html, extracts <body> inner content, detects
    preheader from a hidden <div style="display:none">, builds Unlayer
    design JSON with brand-appropriate body.values (Targo Green link
    color #00C853, Plus Jakarta Sans font, F5FAF7 page background).
  • Backs up existing .json before overwriting.

Generated outputs (committed):
  templates/gift-email-fr.json — 34 KB (30 KB inner HTML + Unlayer chrome)
  templates/gift-email-en.json — 33 KB

Live verification: GET /campaigns/templates/gift-email-fr now returns
{ design: {...Unlayer JSON...} } alongside html. The editor's
onReady() callback in TemplateEditorPage detects data.design and calls
editor.loadDesign(design) → canvas populated immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:22:47 -04:00
louispaulb
a11fe5a115 feat(ops/campaigns): pivot template editor to Unlayer (vue-email-editor)
After honest acknowledgment that easy-email-standard is abandoned and
limited (Chrome-only, no responsive preview, no AMP, no Unsplash, no
file manager), pivoted to Unlayer's vue-email-editor — a Vue 3 native
component giving all the features the user listed for free (internal
use; a small "Powered by Unlayer" badge shows in the sidebar but NOT
in sent emails).

Why drop MJML alongside:
  • MJML was our SERVER-SIDE compilation step because we hand-wrote
    templates. With a visual editor that outputs email-safe HTML
    directly (responsive media queries, Outlook MSO fallbacks, AMP
    where used), the compilation step is redundant.
  • One fewer dependency on the hub (mjml package no longer needed).
  • One fewer file format to persist (.mjml dropped, only .html
    canonical + .json design).

Storage simplification:
  Before: .mjml (source) + .html (compiled) + .json (editor state)
  After:  .html (canonical) + .json (Unlayer design tree)

The hub's send-worker reads .html as before — no changes to send
logic.

Architecture wins:
  • Vue 3 native — zero iframe friction, no postMessage choreography
  • No separate microservice — easy-email container decommissioned
    (docker compose down, code kept under /opt/email-editor/ in case
    of rollback)
  • DNS editor.gigafibre.ca retained but unused — can be removed via
    Cloudflare API cleanup later
  • The editor's mergeTags option exposes our {{firstname}}, {{amount}},
    {{gift_url}}, etc. in Unlayer's native "Merge tags" panel — same
    pattern, more polished UI
  • Features now native: responsive preview (mobile/tablet/desktop
    breakpoints), Unsplash search, file manager, dark mode, design
    history, undo/redo, layers panel, content blocks library

Frontend (TemplateEditorPage.vue):
  • Imports EmailEditor from vue-email-editor
  • onReady() callback: fetch template + loadDesign() to restore canvas
  • saveTemplate(): exportHtml() → PUT { html, design } to hub
  • Top bar kept: template selector, saved chip, preview, test-send,
    save button
  • Removed: iframe-related glue (postMessage listener, iframeKey,
    EDITOR_BASE constant, Cmd-S handling that lived in the iframe)

API client (apps/ops/src/api/campaigns.js):
  • saveTemplate() now accepts opts.design (Unlayer JSON tree) alongside
    content. Legacy opts.format='mjml' still works for backward compat.

Hub (services/targo-hub/lib/campaigns.js):
  • GET /campaigns/templates/:name unconditionally returns
    { name, format, html, design } (+ mjml when format=mjml for
    legacy templates). The design field is null when no .json file
    exists yet.
  • PUT /campaigns/templates/:name HTML save path now accepts
    body.design alongside body.html and persists both with backups.
  • MJML save path (legacy) preserved for any callers using the old
    contract.

Container decommissioned on prod: email-editor container stopped +
removed. The Vue editor lives inside the ops SPA, served from
erp.gigafibre.ca/ops as a normal route.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:14:06 -04:00
louispaulb
bb88a27b90 feat(email-editor): persist easy-email JSON state for instant restore on reload
Phase 2.5 — close the load/save loop so the editor isn't broken by a page
refresh.

Problem: easy-email doesn't ship an MJML→JSON parser, so loading an
existing MJML template into the editor canvas isn't possible. First-time
load = empty canvas. Without this fix, every page reload would also reset
to empty (even after saving), making the editor useless past one session.

Solution: persist easy-email's raw JSON tree (editor state) as a third
companion file alongside .mjml + .html. Editor reads .json on load when
present, falls back to empty otherwise.

Three files per template now:
  gift-email-fr.mjml   — MJML source (rendered by send-worker → already done)
  gift-email-fr.html   — compiled HTML (cached output, sent to recipients)
  gift-email-fr.json   — easy-email editor state (UI restoration only)

Backend (lib/campaigns.js):
- New templateJsonPath() helper + EDITABLE_TEMPLATES checks
- GET /campaigns/templates/:name returns { format, mjml, html, json }
  when format=mjml (json null until first easy-email save)
- PUT /campaigns/templates/:name now accepts body.json alongside body.mjml
  (writes both .mjml + .html [compiled] + .json [editor state])
- Backup hook extended to also backup .json before overwrite

Editor (EmailEditorApp.tsx):
- On load: prefer data.json → parse and seed initialValues. If json
  missing but mjml present, show explanatory error banner + empty canvas
  (user reconstructs once; that save fixes future loads).
- On save: send BOTH mjml (compiled via JsonToMjml) AND raw values
  object as json. Hub persists all three artifacts.

First UX flow on next user visit:
  1. Open editor → empty canvas + banner "MJML exists but no JSON
     editor-state yet; reconstruct once to save a JSON snapshot"
  2. User drag-drops blocks to rebuild the template visually
  3. Click save → MJML + HTML + JSON all persist
  4. Subsequent reloads load from JSON instantly with full editor state

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:04:48 -04:00
louispaulb
2fe8d3f50e feat(campaigns/templates): richer 4-block intro (greeting, hook, gift, upsell)
Expanded the email intro from 3 short paragraphs into 4 semantic blocks,
restoring the marketing-friendly "Tu choisis local..." line that earlier
edits had dropped, plus adding new content about the 3.5 Gbit/s plans
and a "we're right around the corner" CTA framing.

FR intro structure now:
  1. "Bonjour {{firstname}},"
  2. "Tu choisis local, on veut te remercier. / Comme toi, on aime les
     connexions stables et les relations durables." (paired manifesto)
  3. "Avec l'arrivée de l'été, voici un cadeau pour toi, disponible
     pour un temps limité."
  4. "Nous offrons maintenant de nouveaux forfaits, jusqu'à 3.5 Gbit/s.
     Que tu souhaites plus de vitesse, battre une autre offre ou juste
     nous jaser, on est juste à côté."

EN translation mirrors the same 4-block structure.

Editorial rationale for block grouping in MJML:
- Each block is its own <mj-text> for independent drag-drop in GrapesJS
- Lines that always travel together (manifesto pair, upsell + CTA pair)
  share one <mj-text> joined with <br/> to reduce component clutter
- Different styles per block (greeting smaller/secondary, manifesto
  larger/bolder, body paragraphs normal) require separate <mj-text>
  components anyway since MJML inherits styling per-block

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:44:16 -04:00
louispaulb
79ae38db60 feat(campaigns): MJML canonical templates + test-send button
Two big moves:

1. Promote MJML to the canonical template format
   - Move gift-email-fr-mjml.{mjml,html} → gift-email-fr.{mjml,html}
   - Create gift-email-en.mjml (English translation of FR MJML)
   - Compile EN MJML → gift-email-en.html
   - Remove obsolete variants:
     • gift-email-fr-simple.html (now replaced by MJML)
     • gift-email-en-simple.html (same)
     • gift-email-fr-mjml.* (renamed to canonical)
   - The old gift-email-fr.html (rich-with-merchant-grid version) is
     backed up as gift-email-fr.legacy-rich.html.bak — kept on disk
     for reference but not in the editable list.
   - EDITABLE_TEMPLATES is now just ['gift-email-fr', 'gift-email-en'],
     both backed by .mjml source + .html auto-compiled output.

2. Add "Envoyer un test" feature
   Backend:
   - POST /campaigns/templates/:name/test-send accepts { to, vars,
     from?, subject? }. Reads compiled .html, renders Mustache vars,
     sends via Mailjet through email.sendEmail with X-MJ-CustomID
     "test-send:<name>:<timestamp>" so webhook events for tests are
     identifiable. Returns { sent, to, from, message_id, bytes }.
   - Default vars are sensible: firstname="Louis", amount="60 $",
     gift_url="https://gft.link/TEST123", etc. User overrides any
     via the request body.

   Frontend (TemplateEditorPage):
   - Toolbar button "Envoyer un test" (orange) — opens a dialog.
   - Dialog has email input + subject + 7 variable inputs
     (firstname, lastname, amount, commitment_months, gift_url,
     description, expiry) with sensible defaults.
   - "Dirty" banner warning: if the user has unsaved changes, the
     test will use the LAST SAVED version (so save first to test the
     latest). Mentions explicitly in card footer.
   - On send: live notification with the message_id + byte count.
     Errors surface clearly.

Verified live in prod:
  POST /campaigns/templates/gift-email-fr/test-send → 200, message_id
  returned, ~32 KB rendered MJML→HTML output, sent from
  TARGO <support@targointernet.com> (Mailjet-validated sender).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:36:35 -04:00
louispaulb
b37270c11d feat(campaigns/editor): MJML mode — proper email-focused visual builder
Pivot the template editor toward email-marketing-grade visual editing
by replacing grapesjs-preset-newsletter (permissive HTML, fails to parse
nested table structures) with grapesjs-mjml (the industry-standard
email markup language used by Mailchimp/Sendgrid/Twilio).

Why MJML: it was specifically designed to solve the "visual editor +
email-safe HTML" problem. You write semantic <mj-section>, <mj-column>,
<mj-button>, <mj-image> components — MJML compiles them to the gnarly
email-safe HTML with Outlook fallbacks + responsive media queries
auto-generated. Source is 3x more compact than hand-written HTML and
parses cleanly in visual editors.

Backend (lib/campaigns.js):

- Add `mjml` (v5, async) dependency. Compilation happens server-side
  at SAVE time only; the send-worker reads pre-compiled .html (no
  per-recipient compile cost).
- Each template can now be in 'mjml' or 'html' format. Detection by
  file extension on disk: .mjml present → format='mjml', otherwise
  format='html'. Source of truth for MJML templates = .mjml file;
  .html is the auto-compiled output kept alongside for the send-worker.
- GET /campaigns/templates → returns { name, format, size } per template.
- GET /campaigns/templates/:name → returns { format, mjml?, html }
  (mjml field present only when format=mjml; html always present).
- PUT /campaigns/templates/:name accepts:
    { mjml: "<mjml>..." }  → compile to HTML, save both .mjml + .html
    { html: "..." }        → save .html only (legacy path, unchanged)
  Compilation errors return 400 with details (MJML validation soft mode).
  Both files backed up as .bak-<ts>.<ext> before overwrite.

Frontend (TemplateEditorPage.vue):

- Detect format from API response on load.
- For format='mjml': swap grapesjs-preset-newsletter for grapesjs-mjml
  plugin. Editor's getHtml() returns MJML source (not compiled HTML);
  Save POSTs the MJML, hub compiles + persists both files.
- For format='html': existing behavior unchanged.
- Editor is destroyed + reinitialized when format changes (different
  plugin sets).
- Custom variable blocks ({{firstname}}, {{amount}}, etc.) work for
  both formats — they're text content, format-agnostic.

API client (apps/ops/src/api/campaigns.js):

- saveTemplate(name, content, { format }) routes to the right PUT body
  shape based on format param.

Prototype: gift-email-fr-mjml — full MJML conversion of the simple
variant, ~7.5 KB MJML source compiling to ~32 KB email-safe HTML with
0 validation errors. All 6 Mustache variables preserved through
compilation (firstname, amount, gift_url, description, commitment_months,
year). User compares the MJML editor experience to the existing HTML
templates and decides whether to migrate the others.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:29:42 -04:00
louispaulb
1af8b3a029 feat(campaigns/templates): add gift-email-{fr,en}-simple variants
Flat single-table-per-section structure (max 1 level of nesting) so that
GrapesJS' preset-newsletter parser can recognize each section as an
editable component. Same brand visuals + content as the rich variants,
but: dropped the 12-logo merchant grid (heaviest part for the editor),
compacted the three info pills into one consolidated card.

Sections (top-level <table width="600">):
  1. Header logo
  2. Greeting + brand-line + offer intro
  3. Compact info card (was 3 pills)
  4. Option 1 chip
  5. Big green CTA button
  6. Prorata refund disclaimer
  7. Option 2 chip + text
  8. Optional expiry notice (Mustache conditional)
  9. Signature
 10. Contact info (outside card)
 11. Dark footer band (logo + address + copyright)

Each section is a standalone <table role="presentation" width="600">
sharing the same #ffffff background. The first and last get the rounded
border-radius, middle sections have no rounding. Result: visually one
unified card, structurally many editable blocks.

Registered both new variants in EDITABLE_TEMPLATES whitelist so the
ops UI editor picks them up. Rich variants gift-email-fr.html and
gift-email-en.html are unchanged — both styles coexist. User compares
in the editor and picks which to standardize on per campaign.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:04:39 -04:00
louispaulb
bbd2b31761 feat(campaigns/templates): new opening line + logo image in dark footer
Per user feedback after seeing the rendered preview:

1. Opening line replaced:
   FR: "Tu choisis local, on veut te remercier." →
       "Comme toi, on aime les connexions stables et les relations durables."
   EN: "You went local — we want to say thanks." →
       "Just like you, we love stable connections and lasting relationships."
   The new line ties the Internet service (stable connections) to the
   relationship framing (lasting), which reads more naturally than the
   previous "we want to thank you" phrasing.

2. Dark footer band cleanup:
   • Removed the CSS-styled TARGO. wordmark (with green dot)
   • Removed the official slogan line "Services de confiance, ..."
   • Replaced with the actual TARGO logo image (img tag at 120px wide)
   The wordmark is now ALWAYS the logo image, never a text styling —
   keeps the brand mark consistent across header and footer.

TODO marker left in the HTML pointing to the white-variant logo: the
brand guide §1 specifies targo-logo-white.svg for dark backgrounds, but
we only have the green variant uploaded on Mailjet (UUID eed4d18c-...).
The green logo on the #1C1E26 Targo Dark bg is readable but not
pixel-perfect with the brand. To fix, upload the white variant via the
new /campaigns/assets/upload endpoint and swap the src in both
templates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:56:17 -04:00
louispaulb
4a4d145465 feat(campaigns/assets): self-hosted image upload + GrapesJS asset manager
Background: existing Mailjet-hosted brand logos in the gift email templates
stay as-is — those URLs are stable and live on Mailjet's CDN. This change
adds infrastructure for ADDITIONAL images the user wants to drop into the
editor going forward (event photos, custom illustrations, technician
photos for service campaigns, etc.) without uploading to Mailjet first.

Why self-hosted: avoids vendor lock-in for new assets, gives us control
over retention + immutable URLs, integrates natively with our GrapesJS
editor's AssetManager. The cost is ~5 MB max per image and one new bind
mount on the hub.

Backend (lib/campaigns.js):

- Storage at services/targo-hub/uploads/ (new bind mount, RW, mounted into
  the container at /app/uploads). Files named by SHA-256 of content for:
  • Automatic dedup (same image twice → same URL, no extra disk)
  • Immutable URLs (content never changes for a given filename)
  • Path-traversal defence (regex-locked filename pattern)

- POST /campaigns/assets/upload — accepts JSON { name, data } where data
  is a data:image/...;base64,... URL. Decodes, validates MIME against
  allow-list (png/jpg/gif/webp/svg), enforces 5 MB cap, hashes, persists,
  returns { url, filename, size, content_type, data: [...] }. The `data`
  array shape matches what GrapesJS' AssetManager expects on upload
  success. Using base64-in-JSON avoids pulling a multipart parser
  dependency — the ~33% encoding overhead is fine for ≤5 MB images.

- GET /campaigns/assets — list all uploaded assets with metadata
  (filename, url, size, modified, content_type).

- GET /campaigns/assets/:hash.<ext> — serve image bytes with
  Content-Type matching the extension + Cache-Control:
  public, max-age=31536000, immutable. The 1-year cache is safe because
  filename = content hash → URL never serves different bytes. Aligns
  with how Gmail's image proxy and Outlook's caching work.

- DELETE /campaigns/assets/:hash.<ext> — admin removal from disk.

- Helpers (persistUpload / readUpload / deleteUpload) live at module
  scope so they can call `path.join` (otherwise shadowed by the `path`
  URL parameter inside handle()).

API client (apps/ops/src/api/campaigns.js):

- listAssets()  → GET /campaigns/assets
- uploadAsset(file) → reads file via FileReader, posts base64 JSON
- deleteAsset(filename) → DELETE the hash-named file

GrapesJS editor (TemplateEditorPage.vue):

- assetManager config with custom uploadFile callback that bypasses
  GrapesJS' built-in multipart uploader. Drag-drop or file-picker
  triggers our base64 upload, on success the URL is added to the
  AssetManager library so it appears in the editor sidebar for reuse.

- onMounted: preload all previously-uploaded assets via listAssets()
  so the user sees their image library immediately when opening the
  editor (no need to re-upload images used in past campaigns).

End-to-end verified live in prod:
  POST /campaigns/assets/upload   → 200 (with data URL JSON body)
  GET  /campaigns/assets          → 200 (list)
  GET  /campaigns/assets/:hash    → 200 (serves PNG bytes)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:53:01 -04:00
louispaulb
d694d889a1 feat(campaigns/templates): replace placehold.co with real Mailjet logos for rows 2-3
User pasted the full HTML block from their Mailjet Passport editor —
extracted the 8 missing CDN URLs for the merchant grid bottom rows and
swapped them into both FR and EN templates.

Final 12-logo grid is now 100% real Mailjet-hosted assets matching the
user's brand-approved visuals (no more placehold.co rectangles):

  Row 1: Amazon, IGA, Tim Hortons, $1 Plus           (already real)
  Row 2: Pizza Pizza, Home Depot, Best Buy, Walmart  (NEW)
  Row 3: Petro-Canada, Esso, Home Hardware, Sobeys   (NEW)

URL pattern: https://xqy3m.mjt.lu/img2/xqy3m/<UUID>/content
Width normalized to 95px (consistent with row 1) instead of the source
template's 300px since our 600px-wide email card means each 25% column
is ~140px effective — 95px image fits with proper margins.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:36:51 -04:00
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
louispaulb
ab7644e6de fix(ops/dispatch): /desk/<DocType>/ broken URL → /app/<slug>/ + add /address/validate hub
Two things ride together because the user noticed the URL bug while
testing the work-in-progress address validation:

1. **Broken Frappe URL pattern.** Three places in the dispatch UI
   were generating `/desk/Service Location/<id>` and
   `/desk/Dispatch Technician/<id>` links — both return "Page not
   found" on Frappe v14+ (= our v16) because the modern desk URL
   format is `/app/<slug>/<id>` where slug is lowercase + hyphens.
   Fixed in:
     • RightPanel.vue (Lieu link in the job details panel)
     • DispatchPage.vue (Lieu in the job ctx menu)
     • DispatchPage.vue (Ouvrir dans ERPNext in the tech ctx menu)

2. **`POST /address/validate` endpoint** on the hub. Wraps the
   existing RQA Supabase search (`address-search.js`) with a
   confidence-scored output:
     • exact_match (boolean) — score >= 0.7
     • best (the top RQA candidate with aq_address_id, lat, lng)
     • candidates[] (top 5 ranked)
     • confidence (0..1)
     • recommendation: validated | review | unmatched
   Score combines civic-number exact match, road-name fuzzy overlap,
   FSA+full postal-code bonuses, and city-name bonus. The endpoint
   is called from ops UI when adding/editing a Service Location to
   auto-populate aq_address_id + canonical lat/lng instead of
   trusting human typing or Mapbox geocode.

(Custom Fields aq_address_id, address_validation_status,
address_validated_at, linked_address have been added on Service
Location via the Frappe REST API in a separate operation — not in
this commit since they're DB-only.)
2026-05-08 11:01:32 -04:00
louispaulb
cbeb61e04e feat(hub+ops): user invite flow sends temp password via Mailjet + dev .env.example
A few connected fixes around the invite UI shipped in 81d61aa:

1. **Bug in 81d61aa**: `auth.js` referenced `erpFetch` without importing
   it, so every invite returned `erpnext.ok=false` with the silent
   "erpFetch is not defined" error in the catch. Imported it from
   ./helpers alongside the other helpers we already used.

2. **Authentik recovery flow not configured** (caught while smoke-testing):
   the brand `auth.targo.ca` has `flow_recovery=None` and no SMTP, so
   `POST /core/users/{pk}/recovery_email/` returned 400 "No recovery
   flow set." Rather than build out a full Authentik recovery flow
   via API (multiple stages, brand patch, SMTP env var changes), the
   hub now generates a strong-but-readable temp password
   (`X7K2-9NQB-4GHM-3RTW` style — no look-alike chars), POSTs it via
   `/core/users/{pk}/set_password/`, and emails it via the existing
   Mailjet SMTP (already wired into lib/email.js for invoice sends).
   Returns `{temp_password, password_set, email_sent}` so the admin
   has a fallback if Mailjet drops the message.

3. **Settings dialog** now shows a credentials panel after submit:
     • Green banner "✓ Courriel envoyé" when email_sent=true
     • Yellow "⚠ transmettez manuellement" when email_sent=false
     • The temp password as a copyable field either way
     • ERPNext User creation status

4. **Dev onboarding**: added `apps/ops/.env.example`,
   `services/targo-hub/.env.example`, and a top-level `docs/SETUP.md`
   that explains the local-dev flow (clone → cp .env.example .env →
   npm install → npx quasar dev). The example envs are commented
   per-section so a new dev knows which keys correspond to which
   external integration. None of the real secrets are checked in —
   the .gitignore already covers .env files.
2026-05-05 19:50:06 -04:00