Commit Graph

82 Commits

Author SHA1 Message Date
louispaulb
341c8e5a64 Planification: mini-timeline positionnée (fenêtre réelle du shift + blocs pris)
Avant: 'J8' ne distinguait pas 7-15 de 9-17 → mêmes créneaux apparents, dispo réelle différente.
Maintenant chaque cellule affiche: chip (lettre) + intervalle '7–15', et une mini-timeline sur un
axe de journée (06:00→21:00) où la fenêtre du shift est positionnée (donc 7-15 à gauche, 9-17 à
droite = visuellement distinctes) avec les blocs de jobs pris (couleur selon charge) → les TROUS
restants = créneaux offrables. Infobulle = intervalle + h occupées/h (%).

- hub occupancyByTechDay renvoie {h, blocks:[{s,e}]} (heures de début réelles des jobs).
- ops: cellWindow/axisPos/shiftStyle/blockStyle, rendu .tl/.tl-shift/.tl-blk + tick midi.
- démo 8 juin: modèles Matinal 7-15 + Décalé 9-17, techs alignés (7→13.8, 9→18.6 surbooké).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:21:04 -04:00
louispaulb
49795f858b Planification: taux d'occupation par cellule (jobs assignés / heures du shift)
Chaque cellule tech×jour avec un shift affiche, sous le chip (J8), une mini-barre + % colorés
(vert <70, orange 70-99, rouge >=100 surbooké) + infobulle = intervalle du shift + h occupées/h.
Occupation = Σ duration_h des Dispatch Jobs planifiés assignés ce jour ÷ Σ heures du shift.

- hub: occupancyByTechDay(start,days) + GET /roster/occupancy → map 'TECH|date': heures.
- ops api: getOccupancy ; PlanificationPage: occCells (computed), cellOcc/occColor/cellInterval,
  rendu barre + q-tooltip, chargé dans loadStats. Données démo semaine 8 juin (45/85/120%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:13:27 -04:00
louispaulb
88b2702489 Copilote: agit vraiment sur les absences (gerer_absence + IROPS rematch) + gig dispo
Bug: le copilote comprenait l'absence mais n'agissait pas → aucun impact. Causes:
1) prompt « par défaut analyse » → ne déclenchait pas l'action ;
2) marquer une absence n'excluait PAS des créneaux (techGaps ne testait que le statut
   global En pause, pas les Tech Availability approuvées par jour) ;
3) loadBookingData calculait unavail mais ne le retournait pas (oubli) → garde inerte.

Fixes:
- roster.js: loadBookingData inclut unavail (buildUnavailability = En pause + absence_from/until
  + Tech Availability approuvées) ; techGaps exclut le tech absent ce jour-là ; export bookingSlots/fetchTemplates.
- roster-assistant.js: nouvel outil gerer_absence = crée l'absence (par jour) + trouve les RDV
  impactés + RÉASSIGNE auto à un autre tech libre du même créneau (IROPS), renvoie les
  « à reporter ». Nouvel outil ajouter_disponibilite (tech à l'acte ouvre un créneau). Prompt
  orienté ACTION (signaler une absence = instruction d'exécution).
- Validé prod (lab): copilote crée l'absence ✓, booking exclut le tech absent (109→104) ✓,
  rematch DJ-…→Antoine même créneau ✓ ; données de test nettoyées.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:43:03 -04:00
louispaulb
7f3ad56188 Booking #56: politique de créneaux offerts + holds temporaires
- getBookingPolicy() (sous-objet 'booking' du fichier policy): lead_hours, day_start/end,
  days_offered, horizon_days, max_per_day, hold_minutes. Appliquée dans bookingSlots →
  cohérent pour /book + vue agent + fit. ignorePolicy au moment de confirmer (slot encore libre).
- Holds en mémoire (Map TTL): /roster/book/hold {date,start,minutes|release} → fenêtre retirée
  des dispos des autres pendant le hold; libérée à la confirmation. Évite le double-booking
  pendant qu'un agent/client choisit.
- roster-assistant: DEFAULT_POLICY.booking + booking_fields/weekdays (descripteurs UI), fusion fine.
- Testé: plage 10-14h filtre bien; hold 10:00 dispo 12→11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:19:42 -04:00
louispaulb
69ad35b9bc Booking /book: grille semaine → jour → Matin/Après-midi (se situer dans le temps)
Avant: liste plate jj/mm peu lisible. Maintenant: bandeau de semaine ('Semaine du 8 – 14 juin'),
en-têtes de jour complets (lundi 8 juin), sections Matin/Après-midi. Sélection 1-3 préférences inchangée.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:04:08 -04:00
louispaulb
42c07d36f2 Fix reschedule: notify-reschedule désassigne (vide date/heure/tech, status open) → /book repropose des créneaux
Avant: le job gardait scheduled_date → page /book court-circuitait en 'déjà confirmé' sans options.
Maintenant un report = vrai retour au pool → le client choisit un nouveau créneau (vérifié: 50 créneaux proposés, SMS livré).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:56:20 -04:00
louispaulb
097e0566ec Reschedule: endpoint aviser-client (lien /book + SMS Twilio + statut 'À reporter') + file 'À reporter' + outil copilote
/roster/job/notify-reschedule (job,phone?) → token /book + SMS + booking_status='À reporter'.
/roster/jobs-to-reschedule → file superviseur. Copilote: outil notifier_client_report.
Testé OK (chemin sans téléphone = sûr). Brique P5 de #55, déclenchable depuis le flux de pause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:28:45 -04:00
louispaulb
412b6f49a6 Copilote roster: outils d'action (marquer_indisponibilite + reassigner_job)
Le copilote peut désormais CHANGER les dispos en langage naturel ('marque Simon malade
aujourd'hui' → crée une Tech Availability approuvée) et réassigner un Dispatch Job, sur
instruction explicite, avec confirmation. Testé OK end-to-end. (SMS client + escalade = suite.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:57:33 -04:00
louispaulb
d1bd268a32 Copilote roster (Gemini Flash, function-calling) + politique de reprise configurable
lib/roster-assistant.js : couche conversationnelle sur le roster (distincte du solveur OR-Tools).
Outils data réels (etat_equipe, jobs_du_technicien) via roster.fetchTechnicians + Dispatch Job.
Ex: 'TECH-4776 malade le 16 juin' → résout le nom, liste les RDV impactés, propose des techs
dispos qualifiés. Routes /roster/assistant + /roster/policy (politique persistée fichier).
Réutilise le moteur geminiChat de lib/agent.js (gemini-2.5-flash). Testé OK avec données réelles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:08:26 -04:00
louispaulb
a855e11476 Store: prix barré par produit via champ Item.store_regular_price
Affiché barré (gris) si > prix de vente (Item Price), sur produits simples + variantes
(les bundles gardent barré=somme des composants). Catalogue lit le champ; page rend <s>.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:55:57 -04:00
louispaulb
5807d58913 Store: catalogue live depuis ERPNext (GET /store/catalog)
- Curation = champ Item.show_in_store (case à cocher) ; catégorie=item_group, prix=Item Price, stock=Bin live
- Bundles via Product Bundle (prix vs somme barrée), variantes mono-attribut (delta prix + stock/valeur)
- Fix filtres erp.list (tableaux, pas objets — listUrl teste .length) + child-tables lues via erp.get parent
- Page: fetch /store/catalog au mount, repli démo, bandeau live/démo, stock simple respecté

Prérequis PG corrigé: retrait ORDER BY NULL (MySQLism) dans erpnext utilities/product.py
(validation de variante) — sinon création de variantes impossible sur PostgreSQL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:23:01 -04:00
louispaulb
87949f933d Boutique matériel — page modèle /store (staging, self-contained Vue 3)
Vitrine fluide inspirée des meilleurs carts : grille produits, visualisation de
variantes (swatches couleur + pills longueur/modèle, prix delta live, rupture barrée),
panier optimistic (drawer slide, steppers, badge animé, toast, taxes QC, total).
Données démo (caméras/boosters/câbles/TP-Link/bundles). Branchement ERPNext (catalog
+ Product Bundle + pricing) = étape suivante. Validé live au navigateur.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:09:47 -04:00
louispaulb
e2104c93f2 Wizard: 2 cases génériques Prix marketing / Prix original (barré) sur toutes les lignes + aperçu client WYSIWYG
- 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>
2026-06-03 18:31:54 -04:00
louispaulb
4b2e6fb698 Install: prix barré 360$ → 240$ (ancre de valeur) + 'financement crédité chaque mois 24 mois' dans contrat + portail signup
Champ install_regular (Service Contract) = valeur affichée barrée; install_fee reste le montant financé/dû réel. Conforme CRTC: 360 = référence marketing seulement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:22:22 -04:00
louispaulb
7267a27cda Portail self-service abonnement (staging /signup): stepper épuré Forfait→Coordonnées→Récap→Confirmation, catalogue réel, capture Lead
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:16:19 -04:00
louispaulb
6ff4a324ca Récap contrat: détail mensuel (forfait + financement install − rabais nouveau client = net), install offerte tant qu'abonné
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:08:08 -04:00
louispaulb
45c31e555f Contrat résidentiel: conformité CRTC 2026-43 (fin du clawback)
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>
2026-06-03 18:05:56 -04:00
louispaulb
f4138cdd75 Roster AI (planification) + prise de rendez-vous client
Solveur OR-Tools (services/roster-solver) : couverture, compétences,
équité, coût chargé, cadence/efficacité, capacité-par-job ; contraintes
dures/souples façon Timefold.

Hub (lib/roster.js) : génération via solveur, publication par réécriture
de semaine (anti-doublons), demande (effectif ou nb de jobs), cadence/coût/
compétences par tech, pause, congés (Tech Availability + approbation),
booking (slots roster-aware / fit 3-dispos / confirm) + portail public /book.
Réessai sur serialization failures frappe_pg ; appels ERP séquentiels.

Ops : page Planification (grille compacte « J8 », multi-shift, drag-select
+ undo/redo, modèles de semaine, éditeur cadence&coût, congés, SMS opt-in),
page Rendez-vous (répartiteur), jobColor tech en pause → tickets rouges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:42:44 -04:00
louispaulb
21e2c846bf feat(ops): Email Queue admin page (view/delete/purge ERPNext outbound)
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>
2026-06-02 17:29:33 -04:00
louispaulb
c4bf18fdcb fix(legacy-report): treat accounts with a company name as commercial
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>
2026-06-02 06:33:25 -04:00
louispaulb
105b0b2a51 feat(ops): per-address competitor column via Québec IHV open data
Replaces the reCAPTCHA-blocked Cogeco scraper with the authoritative Québec
"Accès Internet haute vitesse" open ArcGIS data (providers declared to the
gov by the providers themselves — validated to match Cogeco's own popup).

- hub lib/serviceability.js: address → ADR (Adresse_complete → IdAdresse +
  Etat_hiv, civic+postal match w/ JS street disambiguation) → FRN table
  (IdAdresse → FRN_nom providers + signup URLs). Referer-gated proxy, disk
  cache (90d), polite rate limit. Routes /serviceability/lookup[-batch].
- ops ReportInternetCherPage: "Concurrence (FSI)" column — provider chips
  (Cogeco highlighted), batch-fetched on demand with progress; "Cogeco
  disponible" summary card = churn-risk count; manual Cogeco verify icon kept.

Validated live: 37 Chemin Noël → Cogeco+Targo, 147 Montée Richard → Targo
only, Repentigny → Bell+Cogeco. Endpoints documented in
memory/reference_quebec_ihv.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:40:47 -04:00
louispaulb
ab57a3e135 fix(reports/legacy): freshness from service date, not invoice date
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>
2026-06-01 19:57:09 -04:00
louispaulb
7413743572 fix(reports/legacy): include Internet equipment cats to capture all discounts
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>
2026-06-01 19:46:58 -04:00
louispaulb
94ebb822db feat(reports/legacy): data-freshness banner + recently-expired-discount column
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>
2026-06-01 19:31:41 -04:00
louispaulb
8a9df4b85e fix(reports/legacy): active clients only — exclude terminated + non-customer accounts
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>
2026-06-01 19:21:24 -04:00
louispaulb
b631fabf91 fix(reports/legacy): exclude expired credits; confirm monthly price model
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>
2026-06-01 19:12:49 -04:00
louispaulb
7f06c254c8 feat(ops/reports): "Internet trop cher" legacy report
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>
2026-06-01 19:06:05 -04:00
louispaulb
0fd1e9f6b5 feat(campaigns/templates): Gemini-powered HTML→native converter
Scales the native-block migration from "one template per manual spec"
to "any compiled .html template, one CLI command, ~5 seconds, ~$0.001
per template" via Gemini Flash semantic interpretation.

Pipeline (ai-convert-to-native.js):
  1. Read existing compiled .html
  2. Send inner body to Gemini Flash with a tight JSON schema
     (block.type ∈ text/image/button/divider/html, plus type-specific
     fields like fontSize/color/padding/href).
  3. AI returns { preheader, ariaLabel, blocks: [...] }
  4. Deterministic emit of a templates-spec/<name>-native.js file —
     no AI-touched markup goes into the final compiled output.
  5. Validation: every {{var}} in source MUST survive into the spec;
     warn loudly if any are dropped (the AI occasionally omits minor
     placeholders like {{year}} in the copyright line).

Why deterministic emit matters:
  Gemini understands SEMANTICS reliably ("this paragraph is the
  greeting, this div is the CTA, this span is a chip") but
  hallucinates DETAILS when generating final HTML. Splitting the
  responsibilities means the AI only outputs structured JSON
  describing the layout, and build-native-template.js produces the
  bytes shipped to recipients.

First conversion: gift-email-fr → gift-email-fr-native
  - 15 blocks identified by Gemini in 3006 tokens (Flash, ~5s).
  - 4 row groups: view-in-browser, white card (intro/chips/CTA/
    footer copy), contact info, dark footer band.
  - 7 text + 1 image + 1 button + 6 html blocks (chips, multi-logo
    strip, brand-logo card, expiry section stay as raw HTML —
    correct, those have no native equivalent).
  - HTML payload: 19,664 bytes vs original 39,913 bytes — **-51%**.
  - One AI omission caught by the new sanity check: {{year}} was
    stripped from the © line in the dark footer. Hand-patched in the
    generated spec. Re-running with stricter prompt should reduce
    that occurrence rate.

Hub preview endpoint now defaults vars.year to current year (matches
the test-send endpoint that already did this), so the sample render
shows "© 2026 TARGO Communications" instead of "©  TARGO ...".

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 18:29:15 -04:00
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
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
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
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
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
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
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