- Prix original (regular_price) disponible sur lignes récurrentes ET ponctuelles (avant: ponctuelles seulement)
- Si Prix original > Prix marketing → barré affiché (sommaire + récap + aperçu live dans l'éditeur)
- Forfait récurrent barré remonte au contrat (monthly_regular) → page d'acceptation affiche <s>99.95</s> 79.95/mois
- Cohérent avec install (install_regular 360→240). Aucun impact CRTC (rabais go-forward, jamais récupéré)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Installation = vraie créance financée (champ install_fee : 240$ std → 10$/mois,
120$ simple → 5$/mois). Résiliation résidentielle ne facture que le solde restant
de l'install (service rendu) ; promotions/carte-cadeau jamais récupérées
(termination_fee_benefits=0). Récap reformulé (financement install, plus de
"promotion non étalée"). Conforme avant l'échéance du 12 juin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces ERPNext's Email Queue in Ops (nav « File courriels ») so ops can see
what's queued — important now that mute_emails=1 + scheduler paused mean nothing
flushes — and delete/purge stale entries without the ERPNext desk.
- hub lib/email-queue.js: GET list (by status, recipients read from each row's
full doc since ERPNext ignores fields on child-doctype REST), DELETE :name,
POST /purge {status}. Wired in server.js.
- ops: api/emailQueue.js + EmailQueuePage.vue (status filter, recipients,
reference, error tooltip, per-row delete + « Purger Not Sent »), route + nav.
Verified live: 13 'Not Sent' (old internal test emails, no invoice refs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
98 business accounts had commercial=0 but a company name (Ferme X Inc.,
Ville de Farnham, Les Jardins Sorel…), leaking into the residential report.
Rule is now: commercial = (commercial flag) OR (company present). Residential
@90$ drops 739→654 (0 company-bearing rows left); commercial 244→276.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Marc Robidoux flagged as overpriced (129.95$) — he has a loyalty discount
(service id 74448) that should lower it. Investigation: 74448 doesn't
exist in the copy (its max service id is 74393), so the discount was added
after the snapshot. Same freshness issue as Julie Dupuis — not a calc bug.
But this also exposed that the freshness banner was wrong: it read the
newest INVOICE date (Apr 30) while the snapshot actually carries SERVICES
created through May 22 — May's recurring billing run simply hadn't executed
at dump time, so invoices lag services by ~3 weeks. For a report that reads
active services/plans/discounts, the service date is the right freshness
signal.
fetchDataAsOf now returns both {services, invoices}; data_as_of (shown in
the banner) is the service date (May 22), with last_invoice kept for
reference. The copy is ~10 days stale, not ~1 month. Marc's loyalty credit
still won't show until the copy is refreshed (task #38).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User flagged Claude Bergeron at 99.95$ "including TV". Investigation: the
report already excludes TV (cat 33/34) — his cat-32 Internet subtotal was
94.95. The real issue was the opposite of what it looked like: a -60$
RAB_FTTH_URBA discount on his account lives in cat 26 ("équipement fibre"),
which the report did NOT count. His true net Internet is 44.95$, so he
should drop off the >90$ list entirely (and now does).
Internet equipment categories (26/29 fibre, 7/8 wireless) carry recurring
items that belong on the Internet bill:
- modem/router rentals: FTTH_LOCMOD +10, LOC_TPL +5, LOCRTHG8245 +6.95
- Internet discounts: RAB_FTTH_URBA (hijacked to -60 for Claude)
Added them to CAT_INTERNET_CORE. The existing price_recurr_type=1 filter
still drops one-time install charges (INSTFIBRE -199, etc.) that share
these categories. Verified HVSECTOUR/INSTTELE (odd high-price items in
cat 7/8) have zero active residential services — no aberrations introduced.
Net effect: the report's "net Internet" is now truly net of every
recurring Internet discount, wherever it's categorized. Residential >90$:
554 → 739 (modem rentals legitimately lift borderline bills; deep
equipment-category discounts like Claude's pull others below the line).
TV and téléphonie remain fully excluded.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User correctly spotted that Julie Dupuis shows 114.95$ but actually pays
69.95$ — investigation revealed the legacy COPY (legacy-db container) is a
one-shot snapshot from 2026-05-05 with data through 2026-04-30 and NO
auto-sync. She renegotiated in May (a -50$ discount on service 50999) which
the copy never received. The report was correct vs the copy, but the copy
is ~1 month stale.
Two changes (data-source strategy still pending operator decision —
prod 10.100.80.100:3306 is reachable for a future live/refresh option):
1. data_as_of — the report now reports MAX(invoice.date_orig) from the
copy and the Ops page shows a banner ("Données legacy au 30 avril —
copie figée, N jours"). Turns orange past 7 days so nobody acts on
stale prices unknowingly.
2. recent_expired_discount column — per-address sum of deactivated credit
lines (status=0, price<0) whose actif_until fell in the last 180 days.
Surfaces clients whose discount just lapsed (Julie's RAB24M -15 + RAB_X
-35 expired 2026-03-01), i.e. the prime retention targets whose bill is
about to jump. Shown in amber with a warning icon + tooltip; included in
the CSV.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User flagged that several listed accounts are inactive (Or Viande Inc,
Denis Henderson). Root cause: I filtered service.status=1 but NOT the
account, so terminated accounts carrying an orphan active service line
slipped through. The legacy billing job (LEGACY-ACCOUNTING-ANALYSIS.md
§6.1) bills only when BOTH service.status=1 AND account.status=1.
Three account-level filters added:
- account.status = 1 → drops terminated accounts. Or Viande Inc is
status=4, terminated 2014 (terminate_date set), but still had a
service.status=1 row. 8602 accounts are status=4 vs 6537 status=1.
- account.group_id = 5 → "Client" per account_group. Drops 6 Prospect,
7 Fournisseur, 8 Relais (network infra, e.g. Denis Henderson's
REL_CHRY_CHARLES tower account), 10 Équipement motorisé.
- customer_id NOT LIKE 'PROPRIO%' → 59 landowner-hosts-our-gear accounts
that live in group 5 but aren't paying customers (Denis Henderson's
other account PROPRIOH_STCHARLES). A genuine same-name customer
(Robert Henderson, ROBEH...) correctly stays.
Residential >90$/mo: 983 → 554 (was inflated ~44% by dead/non-customer
accounts). Commercial: 255 → 240.
Ops page note updated to state "comptes clients actifs uniquement" and
list what's excluded.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reviewed against docs/archive/LEGACY-ACCOUNTING-ANALYSIS.md (the migration
audit) which surfaced two things to check in the overpriced-internet report:
1. service.payment_recurrence (0=annual, 2=monthly, 5=semestrial...) —
checked whether per-cycle prices needed /N normalization. They do NOT:
verified a semestrial FTTH1500I carries product.price=109.95, identical
to the monthly one (billed 6×109.95 every 6 months). Per §6.1
"prix = quantité × prix_unitaire", product.price is already the monthly
unit price. The original monthly logic was correct — no division. The
SKU-LIKE-'%ANN' /12 special-case stays (true annual plans where price
IS the yearly amount, e.g. FTTH_ANN @ 480$/yr).
2. Promo credits carry an actif_until end date (§10). A discount line whose
actif_until is past no longer reduces today's bill, so counting it
understates what the client actually pays. Now excluded.
NULL-safety: the exclusion needs an explicit `actif_until IS NOT NULL`
guard — without it, `NOT (price<0 AND actif_until>0 AND actif_until<now)`
evaluates to NULL for permanent credits (actif_until NULL), which SQL
treats as not-true and silently DROPS every permanent credit line. That
briefly inflated the residential count to 3330; with the guard it's a
correct 1000 (vs 983 before — +17 addresses whose only sub-90 reason was
a now-expired credit).
Net effect: the report reflects the *current* real monthly Internet bill.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New Ops report to surface clients whose net monthly Internet bill
exceeds a threshold — for spotting plans that should be revised.
Hub (lib/legacy-reports.js — new module, read-only MariaDB):
- GET /reports/legacy/overpriced-internet (+ .csv variant)
- Queries the legacy gestionclient DB directly via a small mysql2 pool
(reuses cfg.LEGACY_DB_* — same vars as auth.js sync-legacy; added
LEGACY_DB_PASS to the hub .env which was previously unset).
- Grain = delivery (service address), NOT account: a multi-unit
building (account 13166 has 82 doors / 205 services) would otherwise
show a single bogus $2117 line instead of ~45 per door.
- Net monthly Internet = SUM of effective per-line price across
Internet categories (32 fibre, 4 wireless, 23 camping + optional
add-ons 16/17/21), discounts included (products with price<0 are
recurring credits like RAB24M -15$).
- Effective price = service.hijack ? hijack_price : product.price.
- Only recurring lines (product.price_recurr_type=1) — excludes
one-time equipment/install charges.
- Annual plans (SKU LIKE '%ANN', e.g. FTTH_ANN @ 480$/yr) normalized
/12 so they compare correctly against a monthly threshold (was
falsely showing $480 → now $40, drops below 90$).
- Excludes TV (33,34) and téléphonie (9) entirely.
Validated counts at 90$/mo: 983 residential, 297 commercial addresses.
Ops UI:
- src/pages/ReportInternetCherPage.vue — threshold/segment/add-ons
filters, summary cards (count, total monthly, avg, discounts),
sortable+filterable table (client, address, net, gross, discount,
plan detail with full tooltip, contact), CSV download.
- Card on the Rapports hub + route /rapports/internet-cher.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Manual workaround for redemption status until /gifts/{uuid} polling
ships (task #25). The trailing path segment of the Giftbit shortlink
is the lookup key for Giftbit's admin search:
http://gft.link/4kpZMApLK4B
→ https://app.giftbit.com/app/rewards?search=4kpZMApLK4B
Surfaced in three places:
- Inventory page row: 🔗 button next to the copy-URL action
- Campaign detail page recipient table: same button next to the
Giftbit shortlink
- CSV report: new giftbit_admin_url column for bulk audits in Excel
(one click per row, no manual concat)
Defensive: only renders if the trailing segment is ≥4 chars (avoids
producing useless searches on malformed/test URLs).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Two issues spotted during first real-data test:
1. parseGiftbitCsv only handled the Campaign-export format (header row
+ columns firstname/lastname/email/gift_url/uuid/...). The Link Order
product Giftbit ships when you pre-buy N links exports a different
format: headerless, one URL per line. Detect this by checking the
first non-empty line: if it starts with http(s):// and has no
comma/pipe/tab separators, treat the whole file as bare URLs. Each
URL maps to one recipient (row-order matching, same as before).
2. The template editor was hard-coded to load the existing
gift-email-fr.html into GrapesJS on mount. Hand-crafted email HTML
with deeply nested tables doesn't parse cleanly into GrapesJS
components, so the visual canvas often renders blank. Two new
toolbar actions to address this:
• "Vide" — clears the canvas to a minimal table-based skeleton.
For composing brand-new templates from scratch in the visual
editor without inheriting the existing template's structure.
Confirms before resetting, then sets dirty=true so the next Save
overwrites the on-disk template (with hub-side backup).
• "Réinitialiser" — reloads the last on-disk version, discarding
any unsaved canvas state. Confirms if dirty.
Plus an amber banner in visual mode (auto-hidden when blank-canvas
is active) explaining that Visual mode is for new templates and
the existing template should be edited in HTML mode.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The /campaigns/:id GET handler uses a wildcard regex /^\/campaigns\/([^/]+)$/
which captures "templates" as a fake campaign id and returns 404 before the
fixed /campaigns/templates routes get a chance to match.
Reorder the handle() chain so the fixed paths (/templates, /webhook) come
first, then the wildcard :id routes. Add a comment block calling out the
ordering requirement so future endpoints don't reintroduce the bug.
Verified live: GET /campaigns/templates returns the editable list,
GET /campaigns/templates/gift-email-fr still returns the HTML.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- lib/campaigns.js (new): full backend for the gift campaign flow.
• Two CSV parsers: parseMapCsv handles the pipe-delimited legacy export
with title preamble; parseGiftbitCsv auto-detects the URL column.
• Multi-strategy customer match against ERPNext: email → phone → civic
+ postal_code on Service Location. Returns confidence score (1.0 /
0.9 / 0.8) and match method. Addresses the 25%-match limitation of
the legacy_delivery_id approach by fanning out to address-based
lookup when email/phone miss.
• Storage: JSON files at data/campaigns/<id>.json with embedded
recipients array. Counters auto-recomputed from recipient statuses
on every save (single source of truth).
• Async send worker: setImmediate fire-and-forget loop, throttle
configurable, broadcasts recipient-update events over SSE topic
campaign:<id> for live UI progress.
• Mailjet webhook handler at POST /campaigns/webhook: matches events
to recipients via X-MJ-CustomID = "<campaign-id>:<recipient-index>"
for O(1) lookup, falls back to MessageID scan if CustomID absent.
• Template CRUD endpoints (GET/PUT /campaigns/templates/:name) with
automatic timestamped backups before overwrite. Path-traversal
guarded by an allow-list (only gift-email-fr editable).
• Mustache section renderer ({{#var}}...{{/var}}) shared with the CLI.
- lib/email.js: accept opts.from override (campaign sender differs from
default MAIL_FROM) and opts.headers passthrough (needed for the
X-MJ-CustomID header that drives webhook → recipient correlation).
Return the nodemailer info object on success instead of a bare bool so
callers can capture info.messageId — legacy truthy checks still work.
- server.js: register /campaigns/* route on the hub router.
- templates/gift-email-fr.html: bundled copy of the campaign template
inside the hub so it's deployable without scripts/ on the path. Kept
in sync manually with scripts/campaigns/templates/gift-email-fr.html.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>