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>
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>
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>
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 e50ea88 and drop
html5-qrcode from both package.json + lockfiles. No JS barcode
library, no camera stream, no polyfills.
2. Equipment UX — TechJobDetailPage.vue was a 186-line stub missing the
Ajouter bottom-sheet (Scanner / Rechercher / Créer), the debounced
SN-then-MAC search, the 5-field create dialog, Type + Priority
selects on the info card, and the location-detail contact expansion.
Port the full UX from apps/field/src/pages/JobDetailPage.vue (526
lines) into the ops module (458 lines after consolidation).
Rebuilt and deployed both apps. Remote smoke test confirms 0 bundles
reference html5-qrcode and the new TechJobDetailPage.1075b3b8.js chunk
(16.7 KB vs ~5 KB stub) ships the equipment bottom-sheet strings.
Docs:
- docs/features/tech-mobile.md — new. Documents all three delivery
surfaces (legacy SSR /t/{jwt}, transitional apps/field/, unified
/ops/#/j/*), Gemini-native scanner pipeline, equipment UX, magic-link
JWT, cutover plan. Replaces an earlier stub that incorrectly
referenced html5-qrcode.
- docs/features/dispatch.md — new. Dispatch board, scheduling, tags,
travel-time optimization, magic-link SMS, SSE updates.
- docs/features/customer-portal.md — new. Plan A passwordless magic-link
at portal.gigafibre.ca, Stripe self-service, file inventory.
- docs/architecture/module-interactions.md — new. One-page call graph
with sequence diagrams for the hot paths.
- docs/README.md — expanded module index (§2) now lists every deployed
surface with URL + primary doc + primary code locations (was missing
dispatch, tickets, équipe, rapports, telephony, network, agent-flows,
OCR, every customer-portal page). New cross-module edge map in §4.
- docs/features/README.md + docs/architecture/README.md — cross-link
all new docs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Apr 22 refactor (41d9b5f) collapsed the tech scanner to Gemini-only
photo capture, dropping the live camera viewport and client-side multi-
barcode detection. Techs lost the fast point-and-scan flow that handles
90% of routine installs.
Restored as a hybrid: html5-qrcode as the primary path (instant, offline,
standard QR/barcode), Gemini kept as a second-chance fallback for hard
labels (damaged stickers, text-only serials, unusual symbologies). Offline
queue + scanEquipmentLabel() preserved unchanged.
Three tabs, defaulting to live camera:
- Caméra — continuous html5-qrcode stream, detection auto-beeps
- Photo — native camera; full-image + 3-strip local scan, Gemini fallback
- Manuel — plain text input
Both apps/field and apps/ops updated in lockstep so nothing drifts while
apps/field is being folded into apps/ops/j.
Run `npm install` in apps/ops/ to pull in html5-qrcode before the next build.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- 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>
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>