Symptôme : un job de camping (« Lac des pins | Anton Rimerov ») pointait sur la RÉSIDENCE du client
(428 Rue George, Lasalle = 45.58,-73.73) au lieu du camping. Le pont géocodait l'adresse de compte.
- buildJob : détection camping en PRIORITÉ MAX via le registre camping_registry — signal = sujet (label
explicite, prioritaire) puis ville/adresse de delivery. Garde-fou : le texte doit contenir « camping » OU
un mot-clé de LIEU spécifique (évite les faux positifs de patronyme, ex. « Daniel Dauphinais »). coord_src='camping'.
La branche update fait écraser les coords existantes par le camping (comme delivery). 20 jobs ouverts re-coordonnés.
- camping_dispatch_backfill.sql : corrige les jobs DÉJÀ dispatchés (que le sync ne re-traite plus car le ticket
legacy a quitté le pool ouvert-3301) → 4 Lac des Pins + 2 SandySun. Anton Rimerov/Germaine Thibert → 45.0624,-73.9113 ✓.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Problème : pour un lot de camping, l'adresse du Service Location = souvent la RÉSIDENCE du client, et la
rue interne (ex. « 2 rue Canard, Lac des Pins ») n'est pas dans le RQA → géoloc fausse. Solution data-driven :
table `camping_registry` (mot-clé ville → nom + adresse principale + GPS fixe), coords relevées dans le
legacy delivery. Application : force lat/long du camping sur tous les lots (match ville normalisée), garde
address_line (n° de terrain visible), linked_address = le camping, statut validated.
Appliqué : Lac des Pins 1242 · Dauphinais 134 · SandySun 28 · Frontière 1 (+ Ensoleillé en registre).
Idempotent + ré-applicable. scripts/migration/camping_registry.sql.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scripts/migration/normalize_service_locations_pass2.sql : pour les SL restées review/unmatched après la
passe 1 (code postal+numéro), rematch par NUMÉRO + VILLE (trigram, normalisation St→Saint) + meilleure rue
par similarité ≥0.30 (rue seule, pas ville-incluse pour éviter le gonflement) → upgrade en validated avec
coords RQA réelles. Récupère les SL au code postal erroné/manquant mais ville valide (ex. Athelstan/Ch Ridge).
Résultat : +264 validated (15 195), unmatched 766→550. GPS sur 17 036/17 111 services (99,6%),
dont 15 195 (89%) rooftop AQ. Les 75 sans GPS = boîtes postales/hors-QC/placeholders/sobriquets camping
(pas de position de service réelle). Idempotent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scripts/migration/normalize_service_locations.sql : rattache chaque Service Location à l'adresse
canonique Adresses Québec (table locale rqa_addresses) par CODE POSTAL + NUMÉRO (clé sélective indexée)
+ meilleure rue par similarité désaccentuée (unaccent + pg_trgm). Les 2 tables étant colocalisées dans
la db ERPNext, tout se fait en SQL (~26 s pour 16 345 lignes, pas d'aller-retour applicatif).
Remplit (sans toucher address_line/city/postal_code = origine préservée → table de translation) :
- aq_address_id = rqa_addresses.id (clé de translation locale ; ⚠ id LOCAL, pas l'uuid AQ officiel absent
de la table locale)
- linked_address = adresse canonique conforme (address_full)
- address_validation_status = validated (sim≥0.20) | review (sim<0.20, ex. surnoms de camping) | unmatched
- latitude/longitude raffinées seulement si validated
Résultat : validated 14 931 (87%) · review 1 414 (8%) · unmatched 766 (4,5%) → 16 345 liés (95,5%).
Idempotent (ne traite que les lignes 'pending'). Couvre la tâche « backfill normalisation 17k SL ».
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scripts/refresh-legacy-report-tables.sh: dumps only the 4 tables the
overpriced-internet report needs (account/delivery/service/product) from the
live legacy DB with --single-transaction (non-locking), verifies the dump is
complete before importing into the local legacy-db copy. ~20MB, seconds.
Creds read from a prod-only .refresh.env (gitignored; .env.example committed).
Used to refresh the copy from the 2026-05-22 snapshot to current
(service 66741→69233, account 15321→15706).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native-block imageBlock factory was emitting img tags wrapped only
by a td with text-align:center. That doesn't actually center the image
because text-align only affects inline content, and the img has
display:block. The result: top header logo and dark-footer logo were
left-aligned despite the textAlign:"center" prop on the block.
Fix: wrap each img in an inner <table align="<textAlign>"> exactly the
way MJML/Litmus/Mailchimp do it. This is the canonical email-client
pattern that works in Outlook 2007-2019 (which ignores margin:0 auto
on inline tables but respects table align attributes).
Also: the AI converter dumped the entire dark footer band into a
SINGLE htmlBlock with malformed table markup (a stray </td> outside
its row). Split into proper image + text native blocks so:
1. The logo inherits the new centered nested-table pattern
2. The URL+copyright text is now individually editable in Unlayer
3. The {{year}} placeholder is in a text block where it belongs
And one AI hallucination correction: the converter assigned
textAlign:"left" to the top header logo (probably because the
surrounding column had align="left" in the MJML output). Original
design intent was centered — fixed in the spec.
Verified live: both logos (140px top, 120px footer) now render with
align="center" on their nested wrapper table.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Until now, every Unlayer-edited template stored as a single giant
"Custom HTML" block (~37 KB). The operator couldn't manipulate the
greeting, the CTA, or the expiry badge independently — they had to
edit raw HTML inside one block.
New scripts/build-native-template.js generates matched .json
(Unlayer design tree) + .html (compiled output) from a JS template
spec under scripts/templates-spec/. Each block becomes a separate
entry in the design tree with its own type:
- 9 text blocks : greeting, urgency, body, expiry, prorata,
Option 2 text, signature, contact, dark footer
- 2 image blocks : header logo, footer logo
- 1 button block : the CTA (🎁 {{amount}})
- 4 html blocks : view-in-browser, Option 1 chip, brand-logo
card, Option 2 chip (kept as raw HTML — too
custom for native equivalents)
gift-email-native-reminder-fr ships as the proof of concept:
- Compiled HTML: 30,867 bytes (vs 39,484 for the MJML-compiled
reminder-fr — saves 22%)
- JSON: 42,274 bytes (essentially same as before, but now broken into
16 individually-editable blocks instead of 1 monster Custom HTML)
What this unlocks in Unlayer:
- Click any text → font / color / size / padding / alignment in the
right panel
- Click the CTA → button-specific controls (corner radius, hover
color, padding)
- Drag-reorder blocks within the email
- Mobile preview reflects each block's responsive defaults
- Save a block to the personal library for reuse in other campaigns
Limitations on the 4 html blocks:
- Chips (Option 1 / Option 2) require raw HTML edit because the
rounded badge styling has no native equivalent
- Brand-logo strip needs precise inline img widths Unlayer can't set
Once the operator validates rendering across Gmail/Outlook/Apple
Mail, we'll port the rest: gift-email-fr/en + the existing reminder
templates can all migrate using the same build script.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Surfaces the WHY of the gift in the reminder body. The original
campaign was sent ~10 days before the reminder fires — recipients
may have forgotten the loyalty/gratitude context, leading to a
"what is this?" reaction when they see the reminder cold.
Adding two words ("pour te remercier" / "as a thank-you") cheaply
reconnects with the original messaging and reinforces TARGO's
relationship framing.
FR: "La carte-cadeau qu'on t'a envoyée pour te remercier peut
s'utiliser chez des centaines de marques canadiennes..."
EN: "The gift card we sent you as a thank-you can be redeemed at
hundreds of Canadian brands..."
Greeting kept as "Petit rappel pour {{firstname}}," — first-name
personalization beats generic "toi" on engagement metrics, and the
firstname auto-clean covers ~99% of recipients.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Some readers (and several modern style guides) read em-dashes as
"AI-written" feel — the user preferred a mix of period (for full
clauses) and comma (for asides) to keep the copy conversational
without the long pause em-dashes impose.
Period when both sides are independent clauses:
- about fiber. They're about people too. (EN main + reminder)
- on Giftbit. Just click your X. (EN main + reminder)
- pas manqué. La carte-cadeau qu'on t'a envoyée… (FR reminder)
- didn't miss it. The gift card… (EN reminder)
Comma when the second half is an aside or starts with "and":
- something special, for a limited time. (EN main)
- right next door, and we genuinely love… (EN main)
- aucun souci, pas besoin… (FR reminder)
- no worries, no need to reply… (EN reminder)
gift-email-fr unchanged — its user-visible text never had em-dashes
(the 3 detected were inside HTML comments).
No hub restart needed: the send worker reads templates fresh from
disk on every campaign run, so the new copy applies on the very next
"Lancer l'envoi" click.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>
The 'sans engagement ni carte-cadeau' wording in Option 2 was confusing
for customers with an existing multi-month commitment — implies their
subscription is commitment-free, which contradicts their actual contract.
Reworded to make zero claims about the customer's commitment status,
just describes what happens if they ignore the email:
FR:
before — 'Ne rien faire. Ton abonnement mensuel se poursuit normalement,
sans engagement ni carte-cadeau.'
after — 'Ne rien faire. Aucun changement à ton abonnement actuel.'
EN (Gemini copywriter version was different from earlier templates):
before — 'Just kick back! Your monthly subscription will continue as
usual, with no commitment and no gift card.'
after — 'Do nothing. No changes to your current subscription.'
Benefits:
• No false claim about engagement status
• Shorter, more direct
• Still preserves the explicit consent UX (customer knows ignoring
is a valid choice without consequence)
• No mention of 'no gift card' — that's implicit from not clicking
the CTA, doesn't need to be stated
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous logo injection commit (d76a922) only matched the FR anchor —
EN was missed because Gemini's copywriter-mode translation rewrote
'at hundreds of brands' as 'to spend at hundreds of your favorite stores'.
Patched EN with the correct anchor + 'and more' caption.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous attempt (commit 3f72608) tried to inject a nested <table>
of logos, but the anchor selection logic looked for a </p> that didn't
exist — Unlayer renders the amount line inside a <div>, not <p>. As a
result the logos never made it into the templates.
This commit fixes it with a simpler approach: directly append the logo
images to the existing amount-text string. No table nesting, no anchor
hunting — just plain inline-block <img> tags right after the
"🎁 {{amount}} chez des centaines de marques" text.
Markup pattern (inserted right after the amount line, before the
closing </div>):
<br><br>
<span style="display:inline-block;">
<img src="...amazon..." width="32" alt="Amazon" ...>
<img src="...timhortons..." width="32" alt="Tim Hortons" ...>
<img src="...walmart..." width="32" alt="Walmart" ...>
<img src="...homedepot..." width="32" alt="Home Depot" ...>
<img src="...iga..." width="32" alt="IGA" ...>
<img src="...homehardware..." width="32" alt="Home Hardware" ...>
<span style="font-size:13px;color:#64748B;">et plus</span>
</span>
Each <img> uses display:inline-block + vertical-align:middle so they
sit on the same horizontal line. width=32 attribute set for Outlook;
height:auto in style preserves aspect ratio. margin-right:6px provides
spacing between logos. Caption ("et plus" / "and more") at the end.
Width math (inside 484px-wide pill): 6 × (32 + 6) = 228 px + caption
~50 px = 278 px. Fits with margin to spare.
EN translation auto-detected the equivalent anchor and inserted
"and more" instead of "et plus".
Live test-send verified for both FR + EN.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two refinements per user feedback:
1. Objective/factual prorata disclaimer (shorter, conditions-of-service tone)
FR:
before — "Si tu annules avant {{commitment_months}} mois, tu rembourses
seulement au prorata des mois restants."
after — "Annulation avant {{commitment_months}} mois : seulement à
rembourser au prorata des mois restants."
EN:
before — "If you cancel before {{commitment_months}} months, you only
refund the prorated amount for the remaining months."
after — "Cancellation before {{commitment_months}} months: only the
prorated amount for the remaining months is refundable."
The colon-prefixed structure ("X : Y") reads like a T&C bullet rather
than a marketing sentence — clearer, less wordy, no subject pronoun.
2. Inline row of 6 merchant logos in the offer info pill
Inserted between the "60 $ chez des centaines de marques" line and the
"Instant activation" line. 6 most recognizable QC brands at 32px wide:
Amazon · Tim Hortons · Walmart · Home Depot · IGA · Home Hardware
Followed by "et plus" / "and more" caption.
Uses the existing Mailjet-hosted brand logos (same URLs as the 4×3 grid
in the older rich variant). 32px width fits comfortably on one line
(~280px total in a 484px-wide pill). Email-safe single-row table layout
with vertical-align middle, padding-right 8px for spacing.
Visual effect: instant recognition for the reader — they see the brands
they'd actually redeem at, without dropping the full 12-logo grid that
bloated the previous design.
Applied to .html + .json (both FR + EN) via anchor-based injection:
finds the "🎁 {{amount}} chez/at hundreds of marques/brands" paragraph,
inserts the logo table immediately after its closing </p>. Both files
remain valid + the Unlayer editor will pick up the new table next load.
Verified live via test-send (35-37 KB output, recipient queue ok).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Iterating on the prorata disclaimer per user feedback. The previous
version ("tu rembourses le prorata non utilisé (20 $/mois)") still
read ambiguously — "non utilisé" could mean "the portion you haven't
spent" which is conceptually confusing for a one-time gift card, and
the hardcoded "$20/month" tied the template to the specific
$60/3-month campaign.
New phrasing makes the math explicit: refund only for the months
you're NOT staying.
FR:
before — "Si tu résilies avant {{commitment_months}} mois,
tu rembourses le prorata non utilisé (20 $/mois)."
after — "Si tu annules avant {{commitment_months}} mois,
tu rembourses seulement au prorata des mois restants."
EN:
before — "If you cancel before {{commitment_months}} months,
you refund the unused pro-rated amount ($20/month)."
after — "If you cancel before {{commitment_months}} months,
you only refund the prorated amount for the remaining months."
Wins:
• Subject ("tu" / "you") explicit — no ambiguity on who refunds
• Logic clarified — refund == months NOT STAYED, not "unused
portion of money" (which doesn't quite map to a one-time gift)
• Generic over campaign params — no hardcoded "$20/month" so the
template works at any gift amount + commitment combination
• "annules" (more common in QC consumer-facing) instead of
"résilies" (slightly more formal/legal-sounding)
Applied via direct find/replace on .html + .json (FR + EN). Live
test-send queued to confirm rendering.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User feedback: the prorata disclaimer was semantically wrong — both FR
("le prorata du montant est remboursable") and EN ("we'll refund the
pro-rated amount") read as if TARGO would refund the customer, when
actually the customer needs to refund the unused portion of the gift
they received if they cancel within the commitment period.
Plus: add the explicit per-month rate ($20/month at $60 / 3 months) so
the customer knows exactly what they'd owe at any cancellation date.
FR:
before — "🪂 En cas de départ avant {{commitment_months}} mois,
le prorata du montant est remboursable."
after — "🪂 Si tu résilies avant {{commitment_months}} mois,
tu rembourses le prorata non utilisé (20 $/mois)."
EN:
before — "🪂 If you decide to leave before {{commitment_months}}
months, we'll refund the pro-rated amount."
after — "🪂 If you cancel before {{commitment_months}} months,
you refund the unused pro-rated amount ($20/month)."
Both changes:
• Subject clarified: customer refunds, not TARGO
• Added explicit per-month value for transparency
• Kept warm tone (informal "tu" / "you")
• Mustache {{commitment_months}} preserved
Applied directly to .html + .json via string substitution (preserves
the Unlayer design tree intact except for that one phrase). The
"$20/month" figure is hardcoded for the current $60/3-month campaign;
a future {{monthly_prorata}} computed variable would generalize but
isn't needed yet.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
User edited gift-email-fr in the Unlayer editor with richer marketing
copy (loyalty thanks, brand manifesto, 3.5 Gbit/s upsell, helpful CTA).
Mirror those edits to the EN template via a one-shot translation script
so the bilingual pair stays in sync for the next campaign send.
Translation strategy: plain-string find/replace mapping with FR
phrases in longest-first order to avoid partial matches. Applied to
BOTH the rendered .html (what the recipient sees) AND the .json
(Unlayer design tree — so re-opening the EN editor preserves the
matching structure).
Mapping coverage:
• Intro paragraphs (greeting, gift announcement, loyalty thanks,
brand manifesto, speed upsell, "we're around the corner")
• Offer info pill (amount, instant activation, commitment)
• CTA button labels (Activer → Redeem, Choisir → Pick)
• Prorata refund disclaimer
• Option 2 "do nothing" text
• Signature ("Merci de faire rouler" → "Thanks for helping...thrive")
• Footer contact info + "Tous droits réservés" → "All rights reserved"
• <html lang="fr"> → <html lang="en">
23/28 translation rules matched; the 5 unused ones were for legacy
phrasing not present in the user's latest save (e.g. the old "Tu
choisis local" line that was replaced by the current intro).
Also: drop the obsolete .mjml source files. Now that Unlayer is the
canonical editor, the MJML→HTML compile pipeline is no longer used
on save (Unlayer outputs HTML directly). The .mjml files were stale
copies from the previous MJML-based editor. Removed from disk on
prod and from git history; rollback via git revert if needed.
Verified live: GET /campaigns/templates/gift-email-en returns the
translated content (9 EN markers detected in HTML). Test-send to
louis@targo.ca queued via Mailjet for visual QA.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Solve the "editor starts blank" problem by writing a one-time converter
that wraps each compiled .html template into a minimal Unlayer design
JSON (one Custom HTML block containing the entire body content). On
next editor load, Unlayer reads .json and renders the template in the
canvas — instant visual fidelity without manual reconstruction.
Strategy choice: Unlayer's "Import HTML" is a Pro-only feature. Building
a real HTML→Unlayer-blocks parser is several days of work. The minimal-
viable conversion (1 row + 1 Custom HTML block) gets the user 90% there
immediately:
• Canvas shows the template visually (Unlayer renders the HTML)
• Variables ({{firstname}}, {{gift_url}}, etc.) preserved as text
• User can edit the HTML directly via the block's side panel
• User can incrementally REPLACE the HTML block with native Unlayer
blocks (Text, Image, Button) for any section they want decomposed —
on their own schedule, not blocking the campaign send
New file: services/targo-hub/scripts/convert-html-to-unlayer.js
• CLI: node scripts/convert-html-to-unlayer.js <template-name>
• Reads templates/<name>.html, extracts <body> inner content, detects
preheader from a hidden <div style="display:none">, builds Unlayer
design JSON with brand-appropriate body.values (Targo Green link
color #00C853, Plus Jakarta Sans font, F5FAF7 page background).
• Backs up existing .json before overwriting.
Generated outputs (committed):
templates/gift-email-fr.json — 34 KB (30 KB inner HTML + Unlayer chrome)
templates/gift-email-en.json — 33 KB
Live verification: GET /campaigns/templates/gift-email-fr now returns
{ design: {...Unlayer JSON...} } alongside html. The editor's
onReady() callback in TemplateEditorPage detects data.design and calls
editor.loadDesign(design) → canvas populated immediately.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Expanded the email intro from 3 short paragraphs into 4 semantic blocks,
restoring the marketing-friendly "Tu choisis local..." line that earlier
edits had dropped, plus adding new content about the 3.5 Gbit/s plans
and a "we're right around the corner" CTA framing.
FR intro structure now:
1. "Bonjour {{firstname}},"
2. "Tu choisis local, on veut te remercier. / Comme toi, on aime les
connexions stables et les relations durables." (paired manifesto)
3. "Avec l'arrivée de l'été, voici un cadeau pour toi, disponible
pour un temps limité."
4. "Nous offrons maintenant de nouveaux forfaits, jusqu'à 3.5 Gbit/s.
Que tu souhaites plus de vitesse, battre une autre offre ou juste
nous jaser, on est juste à côté."
EN translation mirrors the same 4-block structure.
Editorial rationale for block grouping in MJML:
- Each block is its own <mj-text> for independent drag-drop in GrapesJS
- Lines that always travel together (manifesto pair, upsell + CTA pair)
share one <mj-text> joined with <br/> to reduce component clutter
- Different styles per block (greeting smaller/secondary, manifesto
larger/bolder, body paragraphs normal) require separate <mj-text>
components anyway since MJML inherits styling per-block
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Per user feedback after seeing the rendered preview:
1. Opening line replaced:
FR: "Tu choisis local, on veut te remercier." →
"Comme toi, on aime les connexions stables et les relations durables."
EN: "You went local — we want to say thanks." →
"Just like you, we love stable connections and lasting relationships."
The new line ties the Internet service (stable connections) to the
relationship framing (lasting), which reads more naturally than the
previous "we want to thank you" phrasing.
2. Dark footer band cleanup:
• Removed the CSS-styled TARGO. wordmark (with green dot)
• Removed the official slogan line "Services de confiance, ..."
• Replaced with the actual TARGO logo image (img tag at 120px wide)
The wordmark is now ALWAYS the logo image, never a text styling —
keeps the brand mark consistent across header and footer.
TODO marker left in the HTML pointing to the white-variant logo: the
brand guide §1 specifies targo-logo-white.svg for dark backgrounds, but
we only have the green variant uploaded on Mailjet (UUID eed4d18c-...).
The green logo on the #1C1E26 Targo Dark bg is readable but not
pixel-perfect with the brand. To fix, upload the white variant via the
new /campaigns/assets/upload endpoint and swap the src in both
templates.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User pasted the full HTML block from their Mailjet Passport editor —
extracted the 8 missing CDN URLs for the merchant grid bottom rows and
swapped them into both FR and EN templates.
Final 12-logo grid is now 100% real Mailjet-hosted assets matching the
user's brand-approved visuals (no more placehold.co rectangles):
Row 1: Amazon, IGA, Tim Hortons, $1 Plus (already real)
Row 2: Pizza Pizza, Home Depot, Best Buy, Walmart (NEW)
Row 3: Petro-Canada, Esso, Home Hardware, Sobeys (NEW)
URL pattern: https://xqy3m.mjt.lu/img2/xqy3m/<UUID>/content
Width normalized to 95px (consistent with row 1) instead of the source
template's 300px since our 600px-wide email card means each 25% column
is ~140px effective — 95px image fits with proper margins.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Template gift-email-fr.html: switch from Gigafibre indigo to TARGO green
(#019547), use real Mailjet-hosted TARGO logo, adopt retention-offer
layout from the latest mockup (tutoiement, Option 1/Option 2 split,
prorata-refund disclaimer, "L'équipe TARGO" signature). Row 1 of the
merchant grid uses real Mailjet logos (Amazon, IGA, Tim Hortons, $1
Plus); rows 2-3 are placehold.co until URLs are shared.
- send_gift_campaign.js: add {{#var}}...{{/var}} Mustache section support
to the renderer so the optional expiry block disappears cleanly when
--expiry is omitted (was rendering literal tags before). Add new
--commitment-months CLI flag (default 3) for the "Rester encore X mois
ou +" wording.
- setup_mailjet_webhook.js (new): one-shot Node script to register the
Hub callback URL with Mailjet's /v3/REST/eventcallbackurl. Defaults
to a safe event subset (open/click/spam/unsub) that doesn't conflict
with the WP-Mail-SMTP integration already owning sent/bounce/blocked.
--all forces full takeover with a conflict guard requiring
--force-takeover to overwrite existing records. Supports --list and
--delete for inspection / rollback.
- package.json (new): nodemailer dependency for SMTP send.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previous commit (380f3bc) incorrectly claimed Mailjet verified targo.ca
at the domain level. It doesn't — Mailjet validates senders ONE BY ONE,
even when SPF/DKIM/DMARC are correctly published at the domain. The
mistake: SMTP returned `250 OK` on a send-test from support@targo.ca,
but the message was silently dropped on Mailjet's side because that
specific mailbox hadn't been approved.
Validated senders on this Mailjet account:
✓ noreply@targo.ca — hub transactional (invoices etc.)
✓ support@targointernet.com — gift campaigns
`support@targo.ca` is NOT validated, despite being on a domain whose
sibling (`noreply@targo.ca`) is.
Updated:
- README default --from value
- The "sender" section now explains per-sender validation (not
domain-level) and the SMTP-250-but-not-delivered gotcha
- Listed both validated senders explicitly with usage intent
The script itself (send_gift_campaign.js) was already
sender-agnostic — only README guidance changed. New senders are added
in Mailjet console → Sender Domain Verification → Add sender, with
the verification link mailed to the new address.
Validated live with Mailjet: targo.ca is verified at the DOMAIN level
(SPF + DKIM + DMARC published in Cloudflare), so any *@targo.ca sender
works without per-mailbox approval. Tested 1 send from
support@targo.ca → accepted, delivered.
Why support@ rather than noreply@ for campaigns:
- Campaigns INVITE a reply (questions about the gift, "I didn't get
mine", "the link doesn't work", etc.)
- noreply@ is for transactional system mail where there's nothing
useful for a human to reply to
- Different intent → different sender
The hub's transactional emails (invoices, magic links) continue to
use noreply@targo.ca; campaigns specifically use support@targo.ca.
README updated accordingly with the rationale.
Note for future: if we ever want a @gigafibre.ca sender, that's
~30 min of Mailjet setup (add domain, publish SPF/DKIM CNAMEs in
Cloudflare). Not done today because all customer-facing email
flows through targo.ca and support@ is the right mailbox for this
campaign intent.
Adds create_giftbit_campaign.js — Node CLI that POSTs to the Giftbit
API (testbed or production), creates a campaign with
delivery_type=SHORTLINK so Giftbit does NOT send their own English
template emails, polls /gifts?campaign_uuid=... until the redemption
shortlinks are generated, then writes a gifts CSV ready to feed into
send_gift_campaign.js.
Two non-obvious things learned while wiring it up:
1. The right endpoint to get the shortlinks is /gifts (not /links).
/links/{uuid} returned 0 rows on our sandbox account; /gifts has
a `shortlink` field on each gift once delivery_status transitions
from QUEUED → LINKCREATED. Polled with 2s interval, up to 20 tries.
2. delivery_type=SHORTLINK is mandatory. Default is GIFTBIT_EMAIL,
which fires their English template immediately — defeating the
whole point of bridging through our French Mailjet template.
Confirmed in the campaign GET response that delivery_type echoes
back correctly when we send "SHORTLINK".
Validated end-to-end (entirely synthetic data — Alice/Bob/Charlie at
@example.com, no real customer info in the sandbox):
✓ Auth probe via /ping returns 200
✓ POST /campaign returns campaign UUID
✓ After ~12s, /gifts returns 3 gifts each with a working shortlink
✓ send_gift_campaign.js consumes the gifts CSV + the contacts CSV
✓ FR template renders: "Bonjour Alice", http://gtbt.co/7TKGFDBNVZq
embedded in the CTA button href, address in the footer line
The --sandbox flag does double duty: routes the API to
api-testbed.giftbit.com AND replaces every recipient email with
louis@targo.ca so we can't accidentally hit real customer inboxes
with the non-redeemable test gifts.
README updated with the two-stage pipeline (create → send), explicit
warnings about the customer-matching gap (only 25% of source rows
resolve via legacy_delivery_id — the rest use a different ID space
from the source Map tool), and the sandbox-quirk where Giftbit
collapses recipient_name when emails are duplicated.
Token NOT committed — pulled from GIFTBIT_TOKEN env var per the
script's contract. In production we'll store it in the hub's
.env alongside SMTP_USER / SMTP_PASS.
User context: needs to send Giftbit gift cards to 203 customers with a
branded French email instead of Giftbit's English-only default delivery.
Giftbit's own UI/API can issue the gifts but its email is English; this
MVP bridges the gap by taking the gift URLs back from Giftbit, pairing
them with our contact CSV, and sending personalized FR emails through
the Mailjet SMTP that's already wired up for ERPNext invoice mail.
Three files in scripts/campaigns/:
1. send_gift_campaign.js — Node CLI. Two CSV inputs (gifts + contacts),
matches by row order (default) or email key, renders the HTML
template with mustache-style {{firstname}} / {{gift_url}} / etc.,
sends via nodemailer with configurable SMTP + throttle.
--dry-run writes per-recipient previews to disk for visual review
before flipping to live mode. Results CSV with per-row status
(sent / failed / dry-run) + error message + timestamp is written
next to the script for follow-up on failures.
2. templates/gift-email-fr.html — branded French email. Table-based
layout (the only thing that renders consistently in Gmail / Outlook /
iOS Mail / Apple Mail / Bell Sympatico). Indigo gradient header,
centered CTA button, contextual {{description}} line citing the
service address, support contact in the footer, no inline images
(defers to text + colour blocks to dodge image-blocking).
3. contacts_from_legacy.py — replaces the ad-hoc /tmp Python I ran
earlier with a proper repo'd version. Same multi-email handling
options (first / split / skip) as I offered the user; defaults to
"first" = 1 gift per household, which is what they chose. Title-
cases the address with French article rules (de / du / la / aux
stay lowercase, 1re / 2e ordinals stay lowercase too).
4. README.md — end-to-end usage with the actual SMTP env vars from
/opt/targo-hub/.env and the matching strategy decision matrix.
Validated end-to-end with a 5-row dry run: matching works, accents
preserved (Amélie, Geneviève, Marc-André), {{firstname}} interpolates,
gift URLs land in the rendered button href, address shows in the
contextual footer line. Previews written to disk for visual QA.
NOT in this MVP (out of scope, can come next if we end up running
gift campaigns regularly):
- No persistence to ERPNext doctype (no Gift Campaign / Recipient
records — pure CLI, results CSV is the audit trail)
- No click-tracking redirect (the gift_url goes verbatim to the
recipient; Giftbit's own API/dashboard reports redemption status,
which is the more relevant signal than "clicked the link")
- No ops UI page (CLI is fine for one-shot; if this becomes regular
we wrap it in services/targo-hub/lib/gift-campaign.js + a Vue page)
Three legacy data-quality issues that were leaking into ERPNext on every
import run. Caught while auditing C-LPB4's mis-pinned dispatch job.
1. **Postal code embedded in address_line.** Legacy `gestionclient` had
rows like `2200-3 chemin de la riviere de la guerre J0S1B0` with
the postal code concatenated at the end (and the same code repeated
in the dedicated zip column). Caused 48-char address_line on what
should have been a 39-char address. Now stripped at import: a regex
matches `\\s+<FSA><LDU>\\s*$` (with or without space) and removes
it; the dedicated postal_code field carries the canonical form.
2. **Abbreviations + Cobol-style capitalization.** Legacy stored
`2066 Ch De La 1Re-Concession` instead of the canonical
`2066 Chemin de la 1re-Concession`. ABBREV_MAP expands `Ch` →
`Chemin`, `Av` → `Avenue`, `Bd`/`Boul` → `Boulevard`, `Rte` →
`Route`, `St-` → `Saint-`, `Ste-` → `Sainte-`, `Mtl` → `Montréal`.
Title-casing rule preserves French articles lowercase (`de`, `du`,
`des`, `la`, `le`, `les`, `au`, `aux`, `à`, `et`, `sur`, `en`)
and ordinal markers (`1re`, `2e`, `3e`). 96 SLs in production had
the `1Re-Concession` style; they'll be re-normalized on next
migration run.
3. **`connection_type` left empty even when ONT/CPE devices existed.**
Pre-loads device→delivery mapping at import start; if the legacy
delivery has any device whose category/name/model contains "ont",
"onu", "cpe", "fibre", "gpon", or "ftth", we set
connection_type='Fibre FTTH'. Without devices on file, the field
stays empty (rep fills it later) — we don't guess.
4. **`postal_code` normalized too** — `j0s1b0` → `J0S 1B0` (uppercase
+ canonical space). Was being inserted in lowercase no-space form.
Self-tested on 8 representative cases including the actual broken
records found in production (LOC-15903, LOC-6227, LOC-4 / C-LPB4).
These changes affect only re-imports of locations. Existing data
needs a separate backfill script — a follow-up will cover that
either as a one-shot migration or by running the existing
`reimport_subscriptions.py` after this script.
- EquipmentDetail: collapsible node groups (clients grouped by mesh node)
- Signal strength as RSSI % (0-255 per 802.11-2020) with 10-tone color scale
- Management IP clickable link to device web GUI (/superadmin/)
- Fibre status compact top bar (status + Rx/Tx power when available)
- targo-hub: WAN IP detection across all VLAN interfaces
- targo-hub: full WiFi client count (direct + EasyMesh mesh repeaters)
- targo-hub: /devices/:id/hosts endpoint with client-to-node mapping
- ClientsPage: start empty, load only on search (no auto-load all)
- nginx: dynamic ollama resolver (won't crash if ollama is down)
- Cleanup: remove unused BillingKPIs.vue and TagInput.vue
- New docs and migration scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Found GenieACS MariaDB at 10.100.80.100 (NOT 10.5.14.21 as
configured in ext scripts — that IP was stale/blocked).
Provisioning data:
- 1,713 WiFi entries (858 unique Deco MACs → SSID/password)
- 797 VoIP entries (469 unique RCMG ONT serials → SIP creds)
- WiFi keyed by Deco MAC (403F8C OUI), VoIP by ONT serial
Complete chain verified:
ONT serial (RCMG) → fibre table (OLT/slot/port)
→ device table (delivery_id)
→ delivery (account_id → ERPNext customer)
→ VoIP provisioning (SIP credentials)
→ WiFi provisioning (via linked Deco MAC)
Reconciliation: 2,499 RCMG serials addressable, 2,003 have
full fibre+device chain, 282 have VoIP provisioning attached.
3,185 TPLG serials, 2,935 in both fibre and device tables.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Full data export and cross-reference analysis:
- 7,550 GenieACS devices with IPs, deviceId, tags
- 6,720 legacy devices (raisecom, tplink, onu categories)
- 16,056 fibre table entries (OLT frame/slot/port/ontid, VLANs)
- 8,434 legacy services linked to devices
Key finding: CWMP serial ≠ physical serial. Only 22/7,550 devices
are tagged with their physical serial (RCMG/TPLG). Raisecom MAC
is extractable from CWMP serial suffix. TP-Link CWMP serial = sticker
serial for ONT models.
Matching strategy documented: tag-based, MAC-based, OLT port-based.
Recommends bulk tagging via OLT query as first step.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add /acs/export endpoint: dumps all provisions, presets, virtual
params, files metadata in one call (insurance policy for migration)
- Add /acs/provisions, /acs/presets, /acs/virtual-parameters, /acs/files
- Shell script export_genieacs.sh for offline full backup
- TR069-TO-TR369-MIGRATION.md: phased migration plan from GenieACS
to Oktopus with parallel run, provision mapping, CPE batching
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Switch Ops data source from Subscription to Service Subscription (source of truth)
- Reimport 39,630 native Subscriptions from Service Subscription data
- Rename 15,302 customers to CUST-{legacy_customer_id} (eliminates hex UUIDs)
- Rename all doctypes to zero-padded 10-digit numeric format:
SINV-0000001234, PE-0000001234, ISS-0000001234, LOC-0000001234,
EQP-0000001234, SUB-0000001234, ASUB-0000001234
- Fix subscription pricing: LPB4 now correctly shows 0$/month
- Update ASUB- prefix detection in useSubscriptionActions.js
- Add reconciliation, reimport, and rename migration scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- InlineField component + useInlineEdit composable for Odoo-style dblclick editing
- Client search by name, account ID, and legacy_customer_id (or_filters)
- SMS/Email notification panel on ContactCard via n8n webhooks
- Ticket reply thread via Communication docs
- All migration scripts (51 files) now tracked
- Client portal and field tech app added to monorepo
- README rewritten with full feature list, migration summary, architecture
- CHANGELOG updated with all recent work
- ROADMAP updated with current completion status
- Removed hardcoded tokens from docs (use $ERP_SERVICE_TOKEN)
- .gitignore updated (docker/, .claude/, exports/, .quasar/)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 29,178 account_memo → Comment on Customer
- Timestamps converted from unix to datetime
- Author mapped from staff_id → User email
- Visible in Customer page comment section
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>