c0ca5feb6f
41 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
1c5241df69 |
fix(campaigns/wizard): template dropdowns now show non-suffixed templates + refresh
Two issues with the per-language template dropdowns: 1. Strict filter — only -fr / -en templates appeared. Anyone naming a template gift-email-test or gift-email-es (no recognized language suffix) saw nothing show up in either dropdown. 2. Loaded once on mount — creating a template in another tab and switching back to a wizard already open kept showing the stale list. Fix: - Templates without a -fr / -en suffix are added to BOTH dropdowns with a "· sans suffixe de langue" tag so they're discoverable but visually distinct from the recommended ones. - Sort: matching-suffix templates first, then alphabetical. - @popup-show triggers a refresh on every dropdown open. - Visible "refresh" icon in the dropdown's append slot for manual triggering without having to close/reopen the popup. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
10d3745b31 |
feat(campaigns/editor): "Variables" button — visible merge-tag reference
The previous discoverability path was clic-text → floating toolbar → {}
icon, which assumes the user already knows how to invoke Unlayer's merge
tag UI. A direct "Variables" button now opens a dialog listing all 9
placeholders grouped by category (Client / Offre / Système) with their
sample value and a click-to-copy action. Reads from the same mergeTags
config Unlayer consumes — single source of truth, no drift risk.
Banner inside hints at the upcoming CSV-driven custom variable feature.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
bf1253ac58 |
fix(campaigns/list): "Envois" column counted only status=sent
After Mailjet's Event API webhook moves rows from 'sent' to 'opened' or 'clicked', the counters.sent bucket empties and the list page showed 0/N even though every email had successfully landed. Use the same sent+opened+clicked sum as the detail page so the list reflects "emails that left our SMTP" rather than "emails still flagged sent". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
a6cd6ee453 | fix(ops/campaigns): v-pre on translation dialog hint to avoid Vue parser crash on {{ '{{...}}' }} | ||
|
|
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>
|
||
|
|
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>
|
||
|
|
4acb18c7df |
fix(ops/campaigns): drop loadBlank() call + force explicit editor dimensions
Two bugs from the first prod test of the Unlayer editor:
1. `editor.value.loadBlank is not a function` — the loadBlank() method
exists in newer Unlayer versions but NOT in vue-email-editor 2.2
which wraps an older Unlayer. When no design is stored yet, just let
the editor render its default empty state ("No content here. Drag
content from left.") and show a Quasar notification telling the
user how to start. No explicit load call needed.
2. Editor renders cramped/small — the EmailEditor component's nested
iframe doesn't inherit dimensions from Quasar's q-page wrapper.
Wrap the EmailEditor in an explicit-sized container:
<div style="height: calc(100vh - 60px); width: 100%; overflow: hidden;">
Plus pass style="height: 100%; width: 100%" to the EmailEditor itself.
This gives the editor a full viewport-minus-toolbar canvas.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
9dcd32ef6a |
feat(ops/campaigns): group merge tags by category + add toolbar hint
Improvements to the variable insertion UX in the Unlayer editor:
1. Reorganized mergeTags from a flat object into 3 logical groups so
Unlayer's "Merge Tags" dropdown shows them under sub-headers
instead of a long flat list:
• Client (firstname, lastname, email, description)
• Offre (amount, gift_url, expiry, commitment_months)
• Système (year)
Format switched from { id: {name, value} } to grouped array
format (Unlayer accepts both, but groups give better UX).
2. Added `sample` field to each merge tag — Unlayer renders these
as the visible content while editing, so the canvas shows
"Louis Tremblay" / "60 $" / "https://gft.link/abc" instead of
literal "{{firstname}} {{lastname}}". Makes the live preview
look like real content during edit. Substitution still happens
server-side at send time via Mustache.
3. New toolbar hint button (code icon, grey) explaining where to
find merge tags in the Unlayer UI:
"Insertion : clic dans un texte → barre flottante → icône {}
Merge Tags. Marche aussi dans les champs URL (boutons, images,
mailto)."
This addresses a common discoverability issue: users don't
always realize variables work in URL fields too (e.g. setting
a button's "Action URL" to {{gift_url}} so each recipient gets
their own Giftbit link).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
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>
|
||
|
|
f9971e9113 |
feat(ops/campaigns): Phase 2 — switch editor page to easy-email iframe
Replace the broken GrapesJS-mjml integration with an iframe pointing to
the standalone email-editor microservice at editor.gigafibre.ca (created
in Phase 1).
What changed:
- Dropped all grapesjs* imports and ~250 lines of editor init/save/preview
glue code. That logic now lives in the React app on the other side of
the iframe.
- Page becomes a thin wrapper:
• Top bar: back button, template selector, "saved" chip,
"Aperçu inbox" button, "Envoyer un test" button, reload button.
• Below: full-height iframe to editor.gigafibre.ca/?name=<template-name>.
- Template switching: bumping iframeKey forces a fresh iframe load so the
new ?name= param takes effect. Route is updated via router.replace.
- postMessage listener: receives { type: 'email-editor:saved', ts }
from the editor iframe and shows a positive toast + updates the
"Sauvegardé · il y a Xs" chip. Origin-checked against EDITOR_BASE.
- Preview dialog: unchanged — fetches compiled HTML from hub's preview
endpoint and renders in srcdoc iframe.
- Test-send dialog: unchanged from previous version.
Removed (now handled inside the iframe):
- Visual / HTML / Aperçu view-mode toggle (editor.gigafibre.ca handles
all editing modes natively)
- "Vide" / "Réinitialiser" buttons (editor has its own)
- "Annuler" / "Enregistrer" buttons (editor saves itself on Cmd-S /
toolbar button)
- spell-check on textarea (editor handles it)
- GrapesJS asset manager wiring (editor will use its own image picker
in Phase 3)
DNS prerequisite handled separately: editor.gigafibre.ca → 96.125.196.67
created via Cloudflare API (proxied=false to match the existing pattern
that lets Traefik handle Let's Encrypt directly).
Container running on prod via /opt/email-editor/docker-compose.yml,
Traefik routing to Host(`editor.gigafibre.ca`). HTTPS verified live.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
79ae38db60 |
feat(campaigns): MJML canonical templates + test-send button
Two big moves:
1. Promote MJML to the canonical template format
- Move gift-email-fr-mjml.{mjml,html} → gift-email-fr.{mjml,html}
- Create gift-email-en.mjml (English translation of FR MJML)
- Compile EN MJML → gift-email-en.html
- Remove obsolete variants:
• gift-email-fr-simple.html (now replaced by MJML)
• gift-email-en-simple.html (same)
• gift-email-fr-mjml.* (renamed to canonical)
- The old gift-email-fr.html (rich-with-merchant-grid version) is
backed up as gift-email-fr.legacy-rich.html.bak — kept on disk
for reference but not in the editable list.
- EDITABLE_TEMPLATES is now just ['gift-email-fr', 'gift-email-en'],
both backed by .mjml source + .html auto-compiled output.
2. Add "Envoyer un test" feature
Backend:
- POST /campaigns/templates/:name/test-send accepts { to, vars,
from?, subject? }. Reads compiled .html, renders Mustache vars,
sends via Mailjet through email.sendEmail with X-MJ-CustomID
"test-send:<name>:<timestamp>" so webhook events for tests are
identifiable. Returns { sent, to, from, message_id, bytes }.
- Default vars are sensible: firstname="Louis", amount="60 $",
gift_url="https://gft.link/TEST123", etc. User overrides any
via the request body.
Frontend (TemplateEditorPage):
- Toolbar button "Envoyer un test" (orange) — opens a dialog.
- Dialog has email input + subject + 7 variable inputs
(firstname, lastname, amount, commitment_months, gift_url,
description, expiry) with sensible defaults.
- "Dirty" banner warning: if the user has unsaved changes, the
test will use the LAST SAVED version (so save first to test the
latest). Mentions explicitly in card footer.
- On send: live notification with the message_id + byte count.
Errors surface clearly.
Verified live in prod:
POST /campaigns/templates/gift-email-fr/test-send → 200, message_id
returned, ~32 KB rendered MJML→HTML output, sent from
TARGO <support@targointernet.com> (Mailjet-validated sender).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
b37270c11d |
feat(campaigns/editor): MJML mode — proper email-focused visual builder
Pivot the template editor toward email-marketing-grade visual editing
by replacing grapesjs-preset-newsletter (permissive HTML, fails to parse
nested table structures) with grapesjs-mjml (the industry-standard
email markup language used by Mailchimp/Sendgrid/Twilio).
Why MJML: it was specifically designed to solve the "visual editor +
email-safe HTML" problem. You write semantic <mj-section>, <mj-column>,
<mj-button>, <mj-image> components — MJML compiles them to the gnarly
email-safe HTML with Outlook fallbacks + responsive media queries
auto-generated. Source is 3x more compact than hand-written HTML and
parses cleanly in visual editors.
Backend (lib/campaigns.js):
- Add `mjml` (v5, async) dependency. Compilation happens server-side
at SAVE time only; the send-worker reads pre-compiled .html (no
per-recipient compile cost).
- Each template can now be in 'mjml' or 'html' format. Detection by
file extension on disk: .mjml present → format='mjml', otherwise
format='html'. Source of truth for MJML templates = .mjml file;
.html is the auto-compiled output kept alongside for the send-worker.
- GET /campaigns/templates → returns { name, format, size } per template.
- GET /campaigns/templates/:name → returns { format, mjml?, html }
(mjml field present only when format=mjml; html always present).
- PUT /campaigns/templates/:name accepts:
{ mjml: "<mjml>..." } → compile to HTML, save both .mjml + .html
{ html: "..." } → save .html only (legacy path, unchanged)
Compilation errors return 400 with details (MJML validation soft mode).
Both files backed up as .bak-<ts>.<ext> before overwrite.
Frontend (TemplateEditorPage.vue):
- Detect format from API response on load.
- For format='mjml': swap grapesjs-preset-newsletter for grapesjs-mjml
plugin. Editor's getHtml() returns MJML source (not compiled HTML);
Save POSTs the MJML, hub compiles + persists both files.
- For format='html': existing behavior unchanged.
- Editor is destroyed + reinitialized when format changes (different
plugin sets).
- Custom variable blocks ({{firstname}}, {{amount}}, etc.) work for
both formats — they're text content, format-agnostic.
API client (apps/ops/src/api/campaigns.js):
- saveTemplate(name, content, { format }) routes to the right PUT body
shape based on format param.
Prototype: gift-email-fr-mjml — full MJML conversion of the simple
variant, ~7.5 KB MJML source compiling to ~32 KB email-safe HTML with
0 validation errors. All 6 Mustache variables preserved through
compilation (firstname, amount, gift_url, description, commitment_months,
year). User compares the MJML editor experience to the existing HTML
templates and decides whether to migrate the others.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
4a4d145465 |
feat(campaigns/assets): self-hosted image upload + GrapesJS asset manager
Background: existing Mailjet-hosted brand logos in the gift email templates
stay as-is — those URLs are stable and live on Mailjet's CDN. This change
adds infrastructure for ADDITIONAL images the user wants to drop into the
editor going forward (event photos, custom illustrations, technician
photos for service campaigns, etc.) without uploading to Mailjet first.
Why self-hosted: avoids vendor lock-in for new assets, gives us control
over retention + immutable URLs, integrates natively with our GrapesJS
editor's AssetManager. The cost is ~5 MB max per image and one new bind
mount on the hub.
Backend (lib/campaigns.js):
- Storage at services/targo-hub/uploads/ (new bind mount, RW, mounted into
the container at /app/uploads). Files named by SHA-256 of content for:
• Automatic dedup (same image twice → same URL, no extra disk)
• Immutable URLs (content never changes for a given filename)
• Path-traversal defence (regex-locked filename pattern)
- POST /campaigns/assets/upload — accepts JSON { name, data } where data
is a data:image/...;base64,... URL. Decodes, validates MIME against
allow-list (png/jpg/gif/webp/svg), enforces 5 MB cap, hashes, persists,
returns { url, filename, size, content_type, data: [...] }. The `data`
array shape matches what GrapesJS' AssetManager expects on upload
success. Using base64-in-JSON avoids pulling a multipart parser
dependency — the ~33% encoding overhead is fine for ≤5 MB images.
- GET /campaigns/assets — list all uploaded assets with metadata
(filename, url, size, modified, content_type).
- GET /campaigns/assets/:hash.<ext> — serve image bytes with
Content-Type matching the extension + Cache-Control:
public, max-age=31536000, immutable. The 1-year cache is safe because
filename = content hash → URL never serves different bytes. Aligns
with how Gmail's image proxy and Outlook's caching work.
- DELETE /campaigns/assets/:hash.<ext> — admin removal from disk.
- Helpers (persistUpload / readUpload / deleteUpload) live at module
scope so they can call `path.join` (otherwise shadowed by the `path`
URL parameter inside handle()).
API client (apps/ops/src/api/campaigns.js):
- listAssets() → GET /campaigns/assets
- uploadAsset(file) → reads file via FileReader, posts base64 JSON
- deleteAsset(filename) → DELETE the hash-named file
GrapesJS editor (TemplateEditorPage.vue):
- assetManager config with custom uploadFile callback that bypasses
GrapesJS' built-in multipart uploader. Drag-drop or file-picker
triggers our base64 upload, on success the URL is added to the
AssetManager library so it appears in the editor sidebar for reuse.
- onMounted: preload all previously-uploaded assets via listAssets()
so the user sees their image library immediately when opening the
editor (no need to re-upload images used in past campaigns).
End-to-end verified live in prod:
POST /campaigns/assets/upload → 200 (with data URL JSON body)
GET /campaigns/assets → 200 (list)
GET /campaigns/assets/:hash → 200 (serves PNG bytes)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
d897bcedb4 |
feat(campaigns): auto-clean first/last names (QC accents + compound split)
The Map CSV migrated from the legacy ERP carries names with two common
defects: missing French accents (Stephane, Andre, Frederic), and
compound first names that were typed without a separator (Marcandre,
Mariejosee, Jeanphilippe). Sending an email "Bonjour stephane," instead
of "Bonjour Stéphane," reads as sloppy automation. Fix both at parse
time so the user sees the corrected names in Step 2 and can override
inline if the auto-cleaner got it wrong.
Backend (lib/campaigns.js):
- FR_NAME_FIXES — 100+ entry dictionary mapping lowercase no-accent
Québec first names to their canonical accented form (André, Stéphane,
Frédéric, Geneviève, Hélène, Joséée, etc.). Sourced from MIQ baby
names + older-generation curation.
- COMPOUND_PARTS — list of common name parts (jean, marie, anne, marc,
philippe, françois, etc.) that combine into QC compound first names.
When two parts appear concatenated with no separator, the cleaner
splits and hyphenates them. Example: "Marcandre" → ["marc","andre"]
→ "Marc-André" (dictionary then applies accent).
- titleCaseToken — proper Title Case respecting apostrophes (O'Brien,
L'Heureux) and hyphens (Marie-Ève). Uses \p{L} Unicode class so it
works on accented chars correctly.
- cleanName(raw) — full pipeline: trim → Title Case → dictionary
lookup per word → compound split fallback. Applied to firstname AND
lastname in parseMapCsv.
- nameWarning(name) — heuristic flag for cases the cleaner couldn't
confidently handle: digit in name, single letter, abnormally long
without separator (likely two stuck names not in COMPOUND_PARTS).
Returns a short FR description for the UI tooltip.
- parseMapCsv now returns firstname/lastname (cleaned) + firstname_raw/
lastname_raw (original from CSV) + cleaned_changed bool + name_warnings
per recipient. UI uses these to show before/after + flags.
UI (CampaignNewPage Step 2):
- New counter card "Noms à vérifier" — count of recipients with at least
one nameWarning. Only renders if > 0.
- Info banner above the recipients table:
"X nom(s) auto-corrigés (...) Y nom(s) suspects (...)"
- Per-row icons in the firstname + lastname columns:
• ⚠ amber WARNING — cleaner flagged this name as suspicious
(tooltip shows the reason: "deux prénoms collés", "contient un
chiffre", etc.)
• ✨ green AUTO_FIX_HIGH — auto-cleaner changed something at parse
time (tooltip shows the original raw value)
Both icons are tooltip-only — no action required.
- Click any name cell → q-popup-edit opens an inline input. Type the
correction, Enter saves. ESC cancels. This is the manual override
path for any name the auto-cleaner mishandled.
Tests (manual via end-to-end smoke against prod):
STEPHANE TREMBLAY → Stéphane Tremblay ✓ accent + Title Case
marie tremblay → Marie Tremblay ✓ Title Case only
Marcandre Boileau → Marc-André Boileau ✓ compound + accent
Jean Francois Lebrun → Jean François Lebrun ✓ accent only
Mariejosee Lapierre → Marie-Josée Lapierre ✓ compound + double accent
Andre LAPRISE → André Laprise ✓ both fixed
Helene St-Pierre → Hélène St-Pierre ✓ accent, hyphen preserved
Frederic O'Brien → Frédéric O'Brien ✓ accent, apostrophe preserved
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
2b85735006 |
fix(ops/campaigns): clarify Step 2 actions + add inline preview + jump-to-editor
User confusion: the "Approuver — 3 à envoyer" button at the end of Step 2
had a send icon, suggesting it fired emails immediately. It actually
just navigated to Step 3 (the confirmation step). The current flow has
two consent moments (Step 2 approve → Step 3 launch) but the UI made
them look like one.
Three changes to address this:
1. Step 2 navigation button:
- Icon changed from 'send' to 'arrow_forward' — clearly "next step"
- Label changed from "Approuver — N à envoyer" to "Continuer — N prêts"
- Added tooltip explaining the send only happens at Step 3
2. Inline preview dialog:
- New "Aperçu du courriel" button in Step 2 (and Step 3)
- Opens a maximized dialog with an iframe rendering the actual template
via POST /campaigns/templates/:name/preview, using the first sendable
recipient's real data + the campaign params (amount, expiry, etc.)
- FR/EN toggle inside the dialog so the user can verify both templates
before launching a mixed-language campaign
- Defaults to the recipient's own language for first view
- Non-destructive — fires zero emails
3. Always-accessible "Éditer le template" link:
- Persistent button in the page header (visible all 3 steps)
- Plus secondary buttons in Step 2 + Step 3 action rows
- Opens the template editor in a NEW TAB so the wizard's state
(uploaded CSVs, parsed recipients) stays intact in the original
tab — the user can tweak the template, save, switch back, click
"Aperçu" to see the change, then continue with the send
4. Step 3 confirmation hardening:
- Banner color escalated from amber to red (this IS the point of no
return for actual delivery)
- Wrap the launch button click in a Quasar confirm dialog ("Envoyer
N courriel(s) maintenant ? Pas annulable.") — adds a third friction
point against accidental clicks
- Launch button is red (negative) — visually distinct from the green
navigation primaries to signal "destructive action ahead"
- Back-to-Step 2 button renamed "Retour modifier" with arrow_back
icon for clarity
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
d6096fe1f8 |
feat(campaigns): apply real TARGO brand + auto-route FR/EN by Customer.language
Brand audit against the official guide (Feb 2026 v1.0) caught several inconsistencies in the email template: - Wrong primary green: was #019547, should be #00C853 (Targo Green from brand palette). Globally replaced. - Wrong gradient: was #019547→#06a04d, should be 135deg #00C853→#005026 (the official Gradient Targo from the brand). Now using Outlook-safe background-image + bgcolor fallback for solid green on Outlook desktop. - Wrong contact info: facturation@targointernet.com / 514 242-1500 → support@targo.ca / 514 448-0773 / 1 855 888-2746 (per §11 of guide). - Wrong website: targointernet.com + gigafibre.ca → www.targo.ca. - Missing slogan + green dot: footer now ends with the trademark tagline "Services de confiance, tout-en-un, près de chez vous." with the obligatory green period (always FR — it's the trademark, not a marketing line, so stays untranslated in EN template too). - Missing brand fonts: added Space Grotesk (display) + Plus Jakarta Sans (body) via Google Fonts. Wrapped in MSO conditional comments so Outlook desktop skips the request and falls back to Helvetica via the explicit font-family stack on every element. - Wrong body bg / text colors: now #F5FAF7 (Muted) / #1B2E24 (Foreground) per brand semantic palette. - Wrong info-pill bg: was #f3f4f3 → #F5FAF7 (Muted). - Added official dark footer band #1C1E26 (Targo Dark) with white inverted wordmark, slogan, address, copyright. Multilang routing (FR/EN): - lib/campaigns.js matchCustomer now fetches Customer.language (14k FR / 1k EN distribution confirmed on prod). Default 'fr' for unmatched contacts. - New templateForLanguage(lang) helper picks gift-email-<lang>.html, falls back to FR. Resolves 'fr-CA' → 'fr' etc. - sendCampaignAsync pre-loads templates per recipient with an in-memory cache to avoid re-reading from disk on every send. - gift-email-en.html created — English translation of the full FR template, keeping the slogan in French (it's the trademark tagline). - year variable now injected (replaces hardcoded © year). UI (CampaignNewPage): - New "Langue" column in the Step 2 recipient table. Shows a clickable chip (FR primary green / EN blue-grey) that toggles language inline, so a campaign manager can override the ERPNext-resolved language per recipient. - Step 3 recap now shows "Répartition par langue: 145 × FR, 12 × EN" before confirming the send. Spell-check: - TemplateEditorPage HTML mode now has spellcheck="true" + dynamic lang attribute on the textarea, picked from the template name suffix (gift-email-fr → fr, gift-email-en → en). Browser's native dictionary flags typos in real time. AI-grade rewrites deferred to the future /campaigns/ai/rewrite endpoint discussed previously. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
8d9e190c21 |
feat(ops/campaigns): explicit contact↔shortlink pairing review before approve
Step 2 of the new-campaign wizard previously dropped unpaired contacts silently (Math.min(contacts, gifts) iteration) — if you uploaded 5 contacts and 3 gift links, you got 3 recipients in the table with no visible signal that 2 contacts were left out. Step 1 only showed "contacts skipped: N" in a small banner, easy to miss. Surface the imbalance explicitly so the user can decide before sending: Backend (POST /campaigns/parse): - Return unpaired_contacts[] and unused_gifts[] arrays (with row_index for source-CSV cross-reference), in addition to the existing recipients[]. Old leftover_gifts / leftover_contacts counters kept for backward compat. UI (CampaignNewPage Step 2): - New columns in the recipients table: • # (row index from the source CSVs) • Lien-cadeau (truncated shortlink, clickable to verify) These let the user eyeball the contact↔link pairing line by line. - New counter strip: Paires / À envoyer / Client lié / Sans client / Sans lien / Liens surplus - "Sans lien" and "Liens surplus" counters appear only when relevant. - Explicit warning banner explaining what unpaired/unused means (acquire more links and re-upload, or proceed knowing N won't get). - Expansion panel listing each unpaired contact with their row_index + details, so the user can verify which specific contacts will be excluded before approving. - Expansion panel listing each unused gift URL (extra capacity). - "Approuver" button now shows the exact send count: "Approuver — N à envoyer". Disabled when 0. Step 3 recap also reflects sendableCount. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
0186a7318e |
fix(ops/campaigns): correct row counts in Step 1 — Link Order CSV had no header
The Step 1 file-upload widgets displayed `(newlines) - 1` for both CSVs, assuming both files have a header row to discount. This breaks for the Giftbit Link Order export which is headerless (one URL per line): a 3-URL file was showing "2 cartes-cadeaux" because the parser ate URL #1 as a fake header. The backend parser was already correct (detects Link Order vs Campaign format by inspecting the first line). The bug was UI-only — the count display reused the same arithmetic for both formats. Fix: introduce countMapRows / countGiftRows helpers that mirror the backend's format detection. Map CSV subtracts 2 (preamble + header). Gift CSV subtracts 0 for Link Order (headerless) or 1 for Campaign export (with header). Plus a "(format: Link Order)" hint next to the count so the user sees which detection path was taken. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
ff629a6a85 |
feat(campaigns): support Giftbit Link Order CSV + add blank-canvas editor mode
Two issues spotted during first real-data test:
1. parseGiftbitCsv only handled the Campaign-export format (header row
+ columns firstname/lastname/email/gift_url/uuid/...). The Link Order
product Giftbit ships when you pre-buy N links exports a different
format: headerless, one URL per line. Detect this by checking the
first non-empty line: if it starts with http(s):// and has no
comma/pipe/tab separators, treat the whole file as bare URLs. Each
URL maps to one recipient (row-order matching, same as before).
2. The template editor was hard-coded to load the existing
gift-email-fr.html into GrapesJS on mount. Hand-crafted email HTML
with deeply nested tables doesn't parse cleanly into GrapesJS
components, so the visual canvas often renders blank. Two new
toolbar actions to address this:
• "Vide" — clears the canvas to a minimal table-based skeleton.
For composing brand-new templates from scratch in the visual
editor without inheriting the existing template's structure.
Confirms before resetting, then sets dirty=true so the next Save
overwrites the on-disk template (with hub-side backup).
• "Réinitialiser" — reloads the last on-disk version, discarding
any unsaved canvas state. Confirms if dirty.
Plus an amber banner in visual mode (auto-hidden when blank-canvas
is active) explaining that Visual mode is for new templates and
the existing template should be edited in HTML mode.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
611f4ed5a6 |
feat(ops/campaigns): UI module for gift campaigns + GrapesJS template editor
New /campaigns section in the ops SPA, gated by manage_users (proxy until a
dedicated manage_campaigns capability is added).
Pages (apps/ops/src/modules/campaigns/pages/):
- CampaignsListPage: table of all campaigns with status chip + progress
bar (sent/total, with fail count), "Nouvelle campagne" + "Éditer le
template" buttons. Empty state with onboarding copy.
- CampaignNewPage: 3-step Quasar Stepper wizard.
Step 1 — upload Map CSV + Giftbit CSV, configure params (name, amount,
commitment_months, sender, throttle, multi-email handling).
Step 2 — preview the matched send list from POST /campaigns/parse, with
counters (matched/unmatched/excluded), per-row match-method
chip, and exclude/include toggle. Banner warns when CSVs are
mis-aligned (leftover gifts or contacts).
Step 3 — confirmation recap with estimated send duration, then fire
POST /campaigns + POST /campaigns/:id/send and redirect to the
live detail page.
- CampaignDetailPage: per-recipient table with status chips updated live
via EventSource on the campaign:<id> SSE topic. Counters bar
(envoyés / cliqués / queued / échecs / non envoyés), progress bar,
per-row customer-link badge with deep-link into /clients/<id>.
Auto-subscribes to SSE when status is draft|sending; "Lancer l'envoi"
button for draft campaigns.
- TemplateEditorPage: GrapesJS-based visual editor for the campaign
templates. Three view modes (Visuel / HTML / Aperçu) — the HTML mode
is the fallback for our table-heavy hand-crafted template that
GrapesJS-preset-newsletter may parse imperfectly. Aperçu mode calls
POST /campaigns/templates/:name/preview on the hub for live variable
substitution. Custom GrapesJS blocks under "Variables" category for
drag-drop insertion of {{firstname}}, {{amount}}, {{gift_url}},
{{description}}, {{expiry}}, {{commitment_months}}. Saves via PUT
with hub-side backup of the previous version.
Wiring:
- api/campaigns.js: hubFetch wrapper, exports parseCsvs / createCampaign
/ listCampaigns / getCampaign / updateCampaign / sendCampaign +
campaignSseUrl(id) for EventSource subscription, + listTemplates /
getTemplate / saveTemplate / previewTemplate for the editor.
- router/index.js: three new routes under /campaigns. The
/campaigns/templates/:name? route is positioned ABOVE /campaigns/:id
to prevent the wildcard from catching template paths.
- config/nav.js + layouts/MainLayout.vue: "Campagnes" sidebar entry with
Lucide Gift icon.
- package.json: grapesjs + grapesjs-preset-newsletter dependencies.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
1186e50bbe |
fix(ops/client): cancelled subs no longer inflate monthly total + Lieu link in-app
Three connected dispatcher-facing issues from C-LPB4 audit:
1. **Monthly total was wrong on customer cards.** Section subtotal and
`locSubsMonthlyTotal` summed `actual_price` for ALL subscriptions
regardless of status, so cancelled rows (rendered with strikethrough)
still pumped up the displayed billing figure. C-LPB4 showed
"Total mensuel: 86,10$" computed as `196.05 - 109.95 = 86.10`,
where 196.05 included 3 cancelled internet plans (Megafibre 80,
TEST-E2E-FTTH, FTTH100 — all struck through in the UI). Real
active monthly is 5.00$ (109.95 active + 5 frais réseau − 109.95
loyalty rebate). Fixed both `sectionTotal` and `locSubsMonthlyTotal`
/`locSubsAnnualTotal` to filter on `status === 'Active'`.
2. **"Lieu" link from a dispatch task pointed to ERPNext desk** which
shows a raw doctype form (no abonnements, no totals, no contacts —
just the bare fields). Now points in-app to
`#/clients/<customer>?location=<SL>`. ClientDetailPage reads the
query string on mount and:
• scrolls the matching `loc-card` into view
• pulses an indigo halo around it for ~2s so the rep finds it
immediately even when the customer has many service locations.
3. **The shipping/billing distinction was invisible** on the customer
page. Added an "Adresses de livraison" badge next to the "Lieux de
service" section title — clarifies that this section IS the
shipping address, distinct from the (future) billing address that
will live on the Customer record. Cosmetic for now; the data
migration to formalize that distinction is the next step.
These three round out the C-LPB4 audit triggered by the wrong
mapbox-pin location: now the customer card on the dispatcher's
screen shows correct totals, the dispatch link drops them right at
the spot they're trying to reach, and the role of each address-bearing
record is named explicitly.
|
||
|
|
ab7644e6de |
fix(ops/dispatch): /desk/<DocType>/ broken URL → /app/<slug>/ + add /address/validate hub
Two things ride together because the user noticed the URL bug while
testing the work-in-progress address validation:
1. **Broken Frappe URL pattern.** Three places in the dispatch UI
were generating `/desk/Service Location/<id>` and
`/desk/Dispatch Technician/<id>` links — both return "Page not
found" on Frappe v14+ (= our v16) because the modern desk URL
format is `/app/<slug>/<id>` where slug is lowercase + hyphens.
Fixed in:
• RightPanel.vue (Lieu link in the job details panel)
• DispatchPage.vue (Lieu in the job ctx menu)
• DispatchPage.vue (Ouvrir dans ERPNext in the tech ctx menu)
2. **`POST /address/validate` endpoint** on the hub. Wraps the
existing RQA Supabase search (`address-search.js`) with a
confidence-scored output:
• exact_match (boolean) — score >= 0.7
• best (the top RQA candidate with aq_address_id, lat, lng)
• candidates[] (top 5 ranked)
• confidence (0..1)
• recommendation: validated | review | unmatched
Score combines civic-number exact match, road-name fuzzy overlap,
FSA+full postal-code bonuses, and city-name bonus. The endpoint
is called from ops UI when adding/editing a Service Location to
auto-populate aq_address_id + canonical lat/lng instead of
trusting human typing or Mapbox geocode.
(Custom Fields aq_address_id, address_validation_status,
address_validated_at, linked_address have been added on Service
Location via the Frappe REST API in a separate operation — not in
this commit since they're DB-only.)
|
||
|
|
f4ae023302 |
fix(ops/dispatch): surface customer + service-location links from a job + fix bad coords
Two related issues, one PR:
1. **Bad coords** on customer C-LPB4's "Wifi buggy" job (DJ-MNP8WIKT).
Address on file is `691 rue des Hirondelles, Saint-Michel J0L2J0`,
but the saved lat/lng (-73.677086, 45.159206) reverse-geocodes to
`2336 rue René-Vinet, Sainte-Clotilde J0L1W0` — ~9 km away. The
delta matches the Gigafibre HQ default fallback (-73.6756, 45.1599)
pretty closely, suggesting the geocoder either failed silently at
Service Location creation time or got pinned to the HQ centroid.
Fixed live in DB (UPDATE on tabService Location LOC-0000000004 +
tabDispatch Job DJ-MNP8WIKT to lng=-73.5792377, lat=45.2408452,
verified via Nominatim against the typed address). The job pin
should now show on the correct house.
2. **No way to jump from a job to the client** — the dispatcher had
to memorize/type the customer ID. Now both the RightPanel and the
job context-menu surface clickable shortcuts:
• Client → `#/clients/<id>` (opens ClientDetailPage in-app)
• Lieu → `/desk/Service Location/<id>` (opens ERPNext in a new
tab; the ops SPA doesn't have a dedicated SL detail page)
Required wiring `customer` + `serviceLocation` into the job map in
`stores/dispatch.js` — the API (`fetchJobsFast` uses `["*"]`) was
already returning the fields, the store just wasn't surfacing them.
Note on the deeper bug: the SL lat/lng is the source of truth and the
job currently *copies* it at creation time (rather than reading from
the SL link dynamically). If a Service Location's coords are corrected
after a job exists, the job retains stale coords. A follow-up could
either (a) re-read on render, or (b) trigger a backfill when SL coords
change. Out of scope for this fix — for now, the dispatcher who fixes
an SL must also update any open jobs at that location.
|
||
|
|
aa5921481b |
feat: contract → chain → subscription → prorated invoice lifecycle + tech group claim
- contracts.js: built-in install chain fallback when no Flow Template matches on_contract_signed — every accepted contract now creates a master Issue + chained Dispatch Jobs (fiber_install template) so we never lose a signed contract to a missing flow config. - acceptance.js: export createDeferredJobs + propagate assigned_group into Dispatch Job payload (was only in notes, not queryable). - dispatch.js: chain-walk helpers (unblockDependents, _isChainTerminal, setJobStatusWithChain) + terminal-node detection that activates pending Service Subscriptions (En attente → Actif, start_date=tomorrow) and emits a prorated Sales Invoice covering tomorrow → EOM. Courtesy-day billing convention: activation day is free, first period starts next day. - dispatch.js: fix Sales Invoice 417 by resolving company default income account (Ventes - T) and passing company + income_account on each item. - dispatch.js: GET /dispatch/group-jobs + POST /dispatch/claim-job for tech self-assignment from the group queue; enriches with customer_name / service_location via per-job fetches since those fetch_from fields aren't queryable in list API. - TechTasksPage.vue: redesigned mobile-first UI with progress arc, status chips, and new "Tâches du groupe" section showing claimable unassigned jobs with a "Prendre" CTA. Live updates via SSE job-claimed / job-unblocked. - NetworkPage.vue + poller-control.js: poller toggle semantics flipped — green when enabled, red/gray when paused; explicit status chips for clarity. E2E verified end-to-end: CTR-00007 → 4 chained jobs → claim → In Progress → Completed walks chain → SUB-0000100002 activated (start=2026-04-24) → SINV-2026-700012 prorata $9.32 (= 39.95 × 7/30). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
07365d3b71 |
fix(tech-diag): warm-up fetch + no-redirect host so ping matches reality
Techs reported cloudflare.com showing 300+ms on the diagnostic page while OS-level ICMP ping returned 5ms. The gap is entirely protocol overhead: - fetch() ≠ ICMP. Every call pays DNS + TCP + TLS + HTTP on top of the real RTT, which is easily 150–300ms cold on mobile LTE when the radio has to wake the RRC connection. - Bare cloudflare.com redirects 301 → www.cloudflare.com, forcing a second DNS + TCP + TLS handshake for every "ping" and doubling the measured latency. - TechDiagnosticPage.vue was also labeling the full 10MB download time as "Latence", so the number on the speed-test card was never a latency measurement at all. Fixes, applied to both surfaces (Ops /j/diagnostic + Field /diagnostic): - Swap cloudflare.com → 1.1.1.1/cdn-cgi/trace. 88-byte response, no redirect, no keepalive games — canonical "internet is up" endpoint. - Warm-up fetch before every measurement. First call absorbs DNS + TCP + TLS + LTE wake; second call reports steady-state RTT. This applies to checkHosts() (ops) and resolveHost() (field composable). - Split runSpeed() into separate ping + throughput measurements. Ping hits speed.cloudflare.com/cdn-cgi/trace (88 bytes on a warm connection); throughput hits /__down on the same origin so the TLS session is reused. Deployed to production; smoke-verified: - ops bundle TechDiagnosticPage.b925e02c.js contains '1.1.1.1/cdn-cgi/trace' - field bundle DiagnosticPage.38a45f65.js contains the same - zero bare 'cloudflare.com' hostname in either hosts array Files: - apps/ops/src/modules/tech/pages/TechDiagnosticPage.vue - apps/field/src/composables/useSpeedTest.js - apps/field/src/pages/DiagnosticPage.vue Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
30a867a326 |
fix(tech): restore Gemini-native scanner + port equipment UX into ops
The ops tech module at /ops/#/j/* had drifted from the field app in two ways:
1. Scanner — a prior "restoration" re-added html5-qrcode, but the
design has always been native <input capture="environment"> → Gemini
2.5 Flash via targo-hub /vision/barcodes (up to 3 codes) and
/vision/equipment (structured labels, up to 5). Revert useScanner.js
+ ScanPage.vue + TechScanPage.vue to commit
|
||
|
|
90f5f2eaa0 |
fix(field/ops): restore live camera + multi-barcode scanning at /j/scan
The Apr 22 refactor (
|
||
|
|
beb6ddc5e5 |
docs: reorganize into architecture/features/reference/archive folders
All docs moved with git mv so --follow preserves history. Flattens the single-folder layout into goal-oriented folders and adds a README.md index at every level. - docs/README.md — new landing page with "I want to…" intent table - docs/architecture/ — overview, data-model, app-design - docs/features/ — billing-payments, cpe-management, vision-ocr, flow-editor - docs/reference/ — erpnext-item-diff, legacy-wizard/ - docs/archive/ — HANDOFF-2026-04-18, MIGRATION, status-snapshots/ - docs/assets/ — pptx sources, build scripts (fixed hardcoded path) - roadmap.md gains a "Modules in production" section with clickable URLs for every ops/tech/portal route and admin surface - Phase 4 (Customer Portal) flipped to "Largely Shipped" based on audit of services/targo-hub/lib/payments.js (16 endpoints, webhook, PPA cron, Klarna BNPL all live) - Archive files get an "ARCHIVED" banner so stale links inside them don't mislead readers Code comments + nginx configs rewritten to use new doc paths. Root README.md documentation table replaced with intent-oriented index. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
e50ea88c08 |
feat: unify vision on Gemini + port field tech scan/device into /j
- Invoice OCR migrated from Ollama (GPU-bound, local) to Gemini 2.5 Flash via new targo-hub /vision/invoice endpoint with responseSchema enforcement. Ops VM no longer needs a GPU. - Ops /j/* now has full camera scanner (TechScanPage) ported from apps/field with 8s timeout + offline queue + auto-link to Dispatch Job context on serial/barcode/MAC 3-tier lookup. - New TechDevicePage reached via /j/device/:serial showing every ERPNext entity related to a scanned device: Service Equipment, Customer, Service Location, active Subscription, open Issues, upcoming Dispatch Jobs, OLT info. - New docs/VISION_AND_OCR.md (full pipeline + §10 relationship graph + §8.1 secrets/rotation policy). Cross-linked from ARCHITECTURE, ROADMAP, HANDOFF, README. - Nginx /ollama/ proxy blocks removed from both ops + field. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
41d9b5f316 |
feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2
Major additions accumulated over 9 days — single commit per request. Flow editor (new): - Generic visual editor for step trees, usable by project wizard + agent flows - PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain - Drag-and-drop reorder via vuedraggable with scope isolation per peer group - Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved) - Variable picker with per-applies_to catalog (Customer / Quotation / Service Contract / Issue / Subscription), insert + copy-clipboard modes - trigger_condition helper with domain-specific JSONLogic examples - Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern - Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js - ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates - depends_on chips resolve to step labels instead of opaque "s4" ids QR/OCR scanner (field app): - Camera capture → Gemini Vision via targo-hub with 8s timeout - IndexedDB offline queue retries photos when signal returns - Watcher merges late-arriving scan results into the live UI Dispatch: - Planning mode (draft → publish) with offer pool for unassigned jobs - Shared presets, recurrence selector, suggested-slots dialog - PublishScheduleModal, unassign confirmation Ops app: - ClientDetailPage composables extraction (useClientData, useDeviceStatus, useWifiDiagnostic, useModemDiagnostic) - Project wizard: shared detail sections, wizard catalog/publish composables - Address pricing composable + pricing-mock data - Settings redesign hosting flow templates Targo-hub: - Contract acceptance (JWT residential + DocuSeal commercial tracks) - Referral system - Modem-bridge diagnostic normalizer - Device extractors consolidated Migration scripts: - Invoice/quote print format setup, Jinja rendering - Additional import + fix scripts (reversals, dates, customers, payments) Docs: - Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS, FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT, APP_DESIGN_GUIDELINES - Archived legacy wizard PHP for reference - STATUS snapshots for 2026-04-18/19 Cleanup: - Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*) - .gitignore now covers invoice preview output + nested .DS_Store Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
607ea54b5c |
refactor: reduce token count, DRY code, consolidate docs
Backend services: - targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas, extract dispatch scoring weights, trim section dividers across 9 files - modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(), consolidate DM query factory, fix duplicate username fill bug, trim headers (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%) Frontend: - useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into 6 focused helpers (processOnlineStatus, processWanIPs, processRadios, processMeshNodes, processClients, checkRadioIssues) - EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments Documentation (17 → 13 files, -1,400 lines): - New consolidated README.md (architecture, services, dependencies, auth) - Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md - Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md - Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md - Update ROADMAP.md with current phase status - Delete CONTEXT.md (absorbed into README) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
73691668d3 |
feat: tech mobile view integrated into ops app at /j, unassign confirmation
Tech mobile view (erp.gigafibre.ca/ops/#/j): - TechLayout with bottom nav tabs (tasks, scanner, diagnostic, more) - TechTasksPage: rich header with tech name/stats, job cards with priority dots, time, location, duration badges, bottom sheet detail with En route/Terminer buttons + scanner/detail access - TechJobDetailPage: editable fields, equipment list, GPS navigation - TechScanPage: device lookup by SN/MAC, create/link to job - TechDiagnosticPage: speed test + host reachability checks - Route /j replaces legacy dispatch-app tech view Dispatch unassign confirmation: - Dialog appears when unassigning published or in-progress jobs - Warns that tech has already received the task - Cancel/Confirm flow prevents accidental removal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
0c77afdb3b |
feat: dispatch planning mode, offer pool, shared presets, recurrence selector
- Planning mode toggle: shift availability as background blocks on timeline (week view shows green=available, yellow=on-call; month view per-tech) - On-call/guard shift editor with RRULE recurrence on tech schedules - Uber-style job offer pool: broadcast/targeted/pool modes with pricing, SMS notifications, accept/decline flow, overload detection alerts - Shared resource group presets via ERPNext Dispatch Preset doctype (replaces localStorage, shared between supervisors) - Google Calendar-style RecurrenceSelector component with contextual quick options + custom RRULE editor, integrated in booking overlay and extra shift editor - Remove default "Repos" ghost chips — only visible in planning mode - Clean up debug console.logs across API, store, and page layers - Add extra_shifts Custom Field on Dispatch Technician doctype Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
c6b2dd1491 |
refactor: extract composables from 5 largest files — net -1950 lines from main components
DispatchPage.vue: 1320→1217 lines - Extract SbModal.vue + SbContextMenu.vue reusable components - Extract useAbsenceResize composable - Extract dispatch constants to config/dispatch.js ProjectWizard.vue: 1185→673 lines (-43%) - Extract useWizardPublish composable (270-line publish function) - Extract useWizardCatalog composable - Extract wizard-constants.js (step labels, options, categories) SettingsPage.vue: 1172→850 lines (-27%) - Extract usePermissionMatrix composable - Extract useUserGroups composable - Extract useLegacySync composable ClientDetailPage.vue: 1169→864 lines (-26%) - Extract useClientData composable (loadCustomer broken into sub-functions) - Extract useEquipmentActions composable - Extract client-constants.js + erp-pdf.js utility checkout.js: 639→408 lines (-36%) - Extract address-search.js module - Extract otp.js module - Extract email-templates.js module - Extract project-templates.js module - Add erpQuery() helper to DRY repeated URL construction Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
320655b0a0 |
refactor: major cleanup — remove dead dispatch app, commit all backend code, extract client composables
- Remove apps/dispatch/ (100% replaced by ops dispatch module, unmaintained) - Commit services/targo-hub/lib/ (24 modules, 6290 lines — was never tracked) - Commit services/docuseal + services/legacy-db docker-compose configs - Extract client app composables: useOTP, useAddressSearch, catalog data, format utils - Refactor CartPage.vue 630→175 lines, CatalogPage.vue 375→95 lines - Clean hardcoded credentials from config.js fallback values - Add client portal: catalog, cart, checkout, OTP verification, address search - Add ops: NetworkPage, AgentFlowsPage, ConversationPanel, UnifiedCreateModal - Add ops composables: useBestTech, useConversations, usePermissions, useScanner - Add field app: scanner composable, docker/nginx configs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
13dcd4bf77 |
feat: add ops app + CONTEXT.md, simplify URL to /ops/
Ops app (Vue/Quasar PWA) with dispatch V2 integration, tag system, customer 360, tickets, and dashboard. Served via standalone nginx container at erp.gigafibre.ca/ops/ with Traefik StripPrefix + Authentik SSO. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |