Accumulated work on the dispatch/legacy-writeback branch: - Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard, PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs; hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js. - Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js, sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental migration scripts; LegacySyncPage, SupplierInvoices + negative-billing / terminated-active reports. - Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit, olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers, legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets, ClientDetail updates. - docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
396 lines
29 KiB
Markdown
396 lines
29 KiB
Markdown
# TARGO / Gigafibre — OPS Platform Guide
|
||
|
||
> **What this is.** A single reference to the logic behind every major feature of the OPS platform,
|
||
> the *operating conditions* we deliberately built into each one to fit how a Québec fibre ISP actually
|
||
> runs, and the *gotchas* that cost us real time. If you were rebuilding this from scratch, this is the
|
||
> document that would save you months.
|
||
>
|
||
> **Audience.** Us (operators + future maintainers) and anyone evaluating "could we build our own ISP ops stack."
|
||
>
|
||
> **Last synthesized:** 2026-06-13. Treat file:line references as directional — verify against current code.
|
||
|
||
---
|
||
|
||
## 0. TL;DR — the five things that explain everything
|
||
|
||
1. **F is the source of truth for billing. ERPNext is an operational mirror.** Almost every design
|
||
decision flows from this. We never write billing to F; we sync F → ERPNext one-way; we never auto-act
|
||
on a divergence that is just "the mirror hasn't caught up."
|
||
2. **The legacy box (F) is fragile and must be treated like unexploded ordnance.** One SSH at a time,
|
||
read-only, query only indexed columns, never the `account_id` join. A bad query freezes live billing.
|
||
3. **Two apps, one hub.** A Quasar SPA (`apps/ops`, staff desktop) + a field-tech mobile view, both
|
||
talking to a single hand-rolled Node service (`services/targo-hub`) that fans out to F, ERPNext,
|
||
Gmail, Twilio, SNMP, n8n, Stripe, GenieACS, OR-Tools, etc. The hub is the integration brain.
|
||
4. **Status is derived, never imported.** A customer is never flipped "inactive" from F. Cancellation
|
||
happens at the *service* level. Active/inactive truth = F `account.status`, read on demand — not a synced flag.
|
||
5. **Everything real-time is SSE.** No websockets for app state (only Twilio media). The hub publishes
|
||
topics; the SPA subscribes. If a badge/panel is "dead," suspect the single mount point, not the data.
|
||
|
||
---
|
||
|
||
## 1. The core model: F → ERPNext one-way mirror
|
||
|
||
```
|
||
┌─────────────────────┐ one-way, preview-first ┌──────────────────────┐
|
||
│ F (gestionclient)│ ───────────────────────────────▶ │ ERPNext v16 (PG) │
|
||
│ PHP + MariaDB │ clients · adresses · services · │ operational mirror │
|
||
│ 10.100.80.100 │ factures · paiements · tickets │ + OPS-only doctypes │
|
||
│ AUTHORITATIVE │ │ scheduler PAUSED │
|
||
│ for billing │ writes back ONLY via PHP bridge │ mute_emails ON │
|
||
└─────────────────────┘ (X-Ops-Token), never direct SQL └──────────────────────┘
|
||
```
|
||
|
||
- **Why a mirror at all.** F bills correctly but is a closed legacy PHP app with no API, no modern UX,
|
||
no dispatch/CRM/automation surface. ERPNext gives us a queryable data model + a place to bolt on
|
||
dispatch, inbox, campaigns, portals, AI — without touching the billing engine.
|
||
- **Identifiers that glue the two worlds** (memorize these):
|
||
- `Customer.legacy_account_id` = F `account.id`
|
||
- `Service Subscription.legacy_service_id` = F `service.id` — **and the SUB doc name embeds it**:
|
||
`SUB-0000056285` → F service `56285`. (We *almost* lost this correlation; keep it.)
|
||
- `Service Location.legacy_delivery_id` = F `delivery.id`
|
||
- **F `account.status`** is the authority for "is this client active": `1=Actif, 2=Suspendu, 3/4/5=Résilié`.
|
||
`mapAccount` derives `disabled = [1,2].includes(status) ? 0 : 1`.
|
||
- **Sync direction & safety.** `lib/legacy-sync.js` is **preview/read-only by default**; applying a
|
||
change requires an explicit confirm string (`SAFE-ADD`); payment mirroring requires `F-WINS`. The
|
||
heavy invoice/service creation runs in **host cron** `/opt/targo-sync/run.sh` (hourly), not in the hub.
|
||
The hub's DAG (`lib/sync-orchestrator.js`) documents/validates order
|
||
(clients→adresses→services; factures→paiements; tickets separate) and exposes `/sync/status`.
|
||
|
||
> **Operating condition (the big one):** *A divergence is not an action.* "F inactive / ERPNext active"
|
||
> for ~2,500 rows is **noise** (the mirror lag + derived flags), not churn to execute. Reconciliation
|
||
> dashboards must split *actionable* (email/phone/name) from *derived/non-actionable* (`disabled`,
|
||
> `is_suspended`). See §3.7 and §4.
|
||
|
||
---
|
||
|
||
## 2. System topology
|
||
|
||
| Layer | What | Where / how |
|
||
|---|---|---|
|
||
| **Staff SPA** | `apps/ops` — Vue 3 + Quasar, hash-router. Desktop ops console. | Built `quasar build` → `scp dist/spa/* → 96.125.196.67:/opt/ops-app/`. Behind **Authentik SSO**. |
|
||
| **Field-tech view** | `apps/ops` routes under `/j` (`TechLayout`) + a hub-served SPA `/t/<token>`. | Delivered to techs by **SMS magic-link** (JWT). PWA-ish, offline store + camera scan. |
|
||
| **Integration hub** | `services/targo-hub` — Node 20, **hand-rolled `http.createServer`** (no Express). ~70 `lib/*.js` modules, ~28k lines. | Container `targo-hub`, port **3300 not published** (test via `docker exec -i targo-hub node`). Lib bind-mounted `/opt/targo-hub/lib`; deploy = scp lib + `docker restart`. |
|
||
| **Billing truth** | F = `gestionclient` PHP/MariaDB | `10.100.80.100`, reached via a local `legacy-db` **mirror** + the PHP write bridge `ops_reassign.php`. |
|
||
| **Operational store** | ERPNext v16 on **PostgreSQL** | `erp.gigafibre.ca` (96.125.196.67). REST via `token` auth + `Host` header. Scheduler **paused**, `mute_emails` on. |
|
||
| **Address DB** | Québec `rqa_addresses` (5.24M) + `fiber_availability` | Local Postgres (the ERPNext PG), shared pool owned by `lib/address-db.js`. |
|
||
| **Realtime** | SSE pub/sub (`lib/sse.js`) | SPA subscribes `/sse?topics=...`. Only Twilio Media Streams use WebSocket. |
|
||
|
||
**Auth tiers (enforced by one gate at the top of `server.js`):**
|
||
1. **Public** allowlist (`/health`, `/g/`, `/c/`, `/book`, `/field`, `/diag/`, `/webhook/*`, checkout/portal/magic-link, `/conversations/email-ingest|channel-ingest`…).
|
||
2. **Customer self-service** — the **token in the URL is the secret** (signed JWT diag links, web-chat client routes, payment links).
|
||
3. **Staff/admin** — `Authorization: Bearer HUB_SERVICE_TOKEN`, **injected server-side by the ops nginx `/hub/` proxy** (same-origin). Cross-origin apps with no token can only reach public routes.
|
||
- `HUB_GATE` modes: `enforce` (401) | `report` (log-only) | off. `ALWAYS_ENFORCE` list (`/devices`, `/email-queue`, `/campaigns`, `/giftbit`) is locked regardless.
|
||
- Per-handler extra secrets: `X-Mail-Token` (n8n ingest), `HUB_INTERNAL_TOKEN` (`/broadcast`), signed query token (iCal), `X-Ops-Token` (PHP write bridge).
|
||
|
||
> **Gotcha (auth):** use a **same-origin proxy that injects the Bearer token**, NOT Traefik forward-auth
|
||
> alone — cross-origin + SSE breaks under forward-auth. Several routes were historically left open
|
||
> (`/devices`, `/conversations`, `/olt`, `/admin/pollers`); the gate + `ALWAYS_ENFORCE` closed the worst.
|
||
|
||
---
|
||
|
||
## 3. Feature catalog
|
||
|
||
Each entry: **What** · **Logic** · **Operating conditions** (the invariants we built to fit ops) · **Gotchas**.
|
||
|
||
### 3.1 Clients — the 360° file (`/clients`, `/clients/:id`)
|
||
|
||
- **What.** Searchable customer list + a single page that aggregates subscriptions, services, equipment,
|
||
tickets, invoices, payments, notes, and **live modem status** for one customer.
|
||
- **Logic.** `ClientDetailPage.vue` (1,725 lines) orchestrates ~8 composables (`useClientData`,
|
||
`useSubscriptionActions`, `useEquipmentActions`, `usePaymentActions`, `useDeviceStatus`,
|
||
`useSubscriptionGroups`, …). Data is ERPNext doctypes (`Customer`, `Service Location`,
|
||
`Service Subscription`, `Subscription Plan`, `Item`) joined by the `legacy_*` keys.
|
||
- **Operating conditions.**
|
||
- *Effective service price* is **not** a column. F's `service` table has no price → price =
|
||
`hijack ? hijack_price : product.price` (+ `hijack_desc`). `status=1` = active; `date_suspended`
|
||
when `status=1` is just history — **ignore it**.
|
||
- *A customer is never set inactive.* Out-of-zone movers stay visible (cross-sell candidates).
|
||
`disabled`/`is_suspended` are **derived from subscriptions**, never synced from F.
|
||
- *Recurring monthly total can never be negative.* The page shows a red guard if it is, and each
|
||
subscription row carries an `F#id` / "added in OPS" badge so we can trace a bad rebate to its origin.
|
||
- **Gotchas.**
|
||
- `erp.list` **blocks some fields** on ERPNext v16 ("Field not permitted in query"); `lib/erp.js`
|
||
auto-retries by dropping them. The real per-service price field is `monthly_price`, *not* `actual_price`.
|
||
- This page is the #4 largest file — a refactor candidate, but it's load-bearing; split by section, don't rewrite.
|
||
|
||
### 3.2 Communications — unified inbox (`/communications`)
|
||
|
||
- **What.** One place for all customer conversations (SMS + email + web-chat) and open tickets,
|
||
organized two ways: a **department board** ("Par département") and a **sales pipeline** ("Pipeline ventes").
|
||
- **Logic.**
|
||
- `useConversations.js` is a **module-level singleton** holding `conversations`, `discussions`,
|
||
`panelOpen`, `activeCount`, queue/claim/assign, SSE wiring. Backend: `lib/conversation.js`
|
||
(+ `lib/ticket-collab.js` for the ERPNext-Issue side).
|
||
- Inbound mail/SMS/3CX/Facebook are **ingested** via `/conversations/email-ingest` & `/channel-ingest`
|
||
(n8n, `X-Mail-Token`). Replies go out via Gmail (`gmail.js`) or Twilio.
|
||
- `DepartmentBoard.vue` groups `discussions` by `queue` (= department) with drag-to-reassign
|
||
(`assignQueue`), click-to-open, and per-column compose. `PipelineBoard.vue` is the leads kanban.
|
||
- **AI triage** (`inbox-triage.js`): classifies inbound mail (known customer? type? suggest a ticket?).
|
||
- **Outbox / undo-send** (`outbox.js`): multi-recipient queue notifications are held N seconds and
|
||
cancellable from an ops panel; single-recipient agent replies bypass the buffer.
|
||
- **Category taxonomy is single-source** (`lib/categories.js`): the 4 queues, the request `TYPES`,
|
||
`type→queue` (`QUEUE_OF`) and `type→ticket-category` (`CAT`) live in ONE module, required by both
|
||
`conversation.js` and `inbox-triage.js`. They had drifted (`telephonie`/`television` existed in the
|
||
queue map but not the triage types). Contract: the AI prompt enumerates `TYPES` by hand — extend both.
|
||
- **Sender identity = the real `From`** (display name), then a customer matched **by email**, then the
|
||
raw address — *never* the AI's content-guessed name. And a thread is **never grouped by one of our own
|
||
domains** (`support@targo.ca`, `*@targointernet.com`): re-ingested outbound would otherwise collapse
|
||
unrelated threads into one. Per-agent reply stats come from stamping `msg.agent` on outbound replies.
|
||
- **Operating conditions.**
|
||
- *Departments = the four queues* `Facturations`, `Service à la clientèle`, `Supports`, `Technicien`
|
||
(+ "Non assigné"). The board reuses the existing queue labels — departments are queues.
|
||
- *Queue/notification lists must stay small.* A queue notification once emailed the **entire** team and
|
||
re-ingested our own notifications into the inbox (a feedback loop). Fix: empty `queue_members` +
|
||
an `ingestGuard` that drops own-notifications / internal mail / ticket-replies.
|
||
- *AI auto-reply only answers customer-initiated threads* — never our own "planned maintenance"/dunning
|
||
notifications, never a bare "ok/merci" ack (`isAck`), and yields if a human replied today.
|
||
- *Sales pipeline coexists with the department board* (2 tabs) — we did **not** replace it, because the
|
||
Sales-sync project still needs the leads stages.
|
||
- **Gotchas.**
|
||
- **`ConversationPanel.vue` is the single mount point for the whole comms subsystem.** Its `onMounted`
|
||
runs the global init (`fetchList`+`fetchTickets`+SSE); the drawer (`panelOpen`), the badge
|
||
(`activeCount`), **and** the "Nouveau texto/courriel" dialog all live inside it. If its `setup()`
|
||
throws, *everything* comms dies (no badge, panel won't open, compose won't open) while the rest of the
|
||
app keeps working. That exact symptom pattern = a ConversationPanel mount failure — **not the cache.**
|
||
- The throw we actually hit: a top-level `watch(coordToken, …)` placed **before** `const coordToken = computed()`
|
||
→ TDZ `ReferenceError`. Rule: in `<script setup>`, any top-level `watch()`/eager call must come
|
||
*after* the `const` it references. The Quasar build does **not** catch this (runtime error).
|
||
- `/email-queue` (`EmailQueuePage`) is the **ERPNext muted outbound queue** (a technical inspector),
|
||
*not* the Gmail conversation email. Don't confuse them.
|
||
|
||
### 3.3 Tickets & collaboration (`/tickets`, `/collab/*`)
|
||
|
||
- **What.** ERPNext `Issue` list + a "collab" layer: customer search (phone/civic/name fuzzy),
|
||
service+modem status, live modem checks, create-ticket-from-conversation, comments/@mentions.
|
||
- **Logic.** `ticket-collab.js` exposes `/collab/service-status` (service + modem in one call),
|
||
`/collab/dostuff` (live modem status by hitting **F's own n8n webhook** `n8napi.targo.ca/webhook/dostuff`),
|
||
the negative-billing & terminated-active reports, WAN-rate, and `/collab/diag-link`.
|
||
- **Operating conditions.**
|
||
- A ticket created from a conversation **keeps the conversation** (≠ archive).
|
||
- "Do Stuff" live check is `tech=3` only (`tech=2` is our own poller); ~27 s latency.
|
||
- **Gotchas.** `service-status` keys ONUs by the **same TPLG serial format** as ERPNext (MAC fallback);
|
||
`rxPowerOnu` is RAW (not dBm), `rxPowerOlt` is dBm×10. ERPNext TPLG serials ≠ GenieACS real serials.
|
||
|
||
### 3.4 Workforce — Dispatch + Planification + RDV + Copilot
|
||
|
||
This is the largest, most interconnected domain. The strategic move was **convergence**: Dispatch and
|
||
Planification share a **single write path** and a **single resource rail**.
|
||
|
||
- **Dispatch** (`/dispatch`, `DispatchPage.vue` 2,162 lines): drag-and-drop tech scheduling on a
|
||
Mapbox-backed board — routing, auto-distribute, **Uber-style job offers** (`useJobOffers`: broadcast/
|
||
accept/decline, rush pricing), GPS, undo. Backend `lib/dispatch.js` scores techs by proximity/cost/skills.
|
||
- **Planification** (`/planification`, 3,524 lines — the largest file): roster/shift grid driven by an
|
||
**OR-Tools solver** (`roster-solver:8090`) via `lib/roster.js`, over ERPNext doctypes (Dispatch
|
||
Technician, Shift Template/Requirement/Assignment, Tech Availability).
|
||
- **RDV** (`/rdv`): customer-appointment booking — roster-aware slots, 3 ranked offers + "fit", hold,
|
||
confirm, reschedule-notify. Public booking served at `/book`.
|
||
- **Copilot** (`/copilote`): conversational ops assistant (Gemini function-calling) with **write actions**
|
||
(reassign/SMS/escalate) + voice.
|
||
- **Historique** (`/historique`, `HistoriquePage.vue`): 3 tabs — *Tournées par technicien* (a day's jobs
|
||
per tech, **all statuses** incl. Completed/Cancelled), *Classement techniciens* (jobs completed), and
|
||
*Classement Inbox* (replies + threads handled per agent). It exists because the Dispatch board filters to
|
||
`open/assigned/in_progress`, so once a job is completed/cancelled (or its ticket closed/reassigned) it
|
||
vanished — "where did the tech go?". Read-only hub endpoints `/dispatch/history`, `/dispatch/leaderboard`,
|
||
`/conversations/leaderboard`; agent reply stats rely on `msg.agent` stamping (accrue from 2026-06-14).
|
||
- **Operating conditions.**
|
||
- *One write path.* Job→tech assignment goes through `features/workforce/useAssignment` (the "hub"),
|
||
shared by both the Dispatch board and the Planification grid — so the two views can't drift.
|
||
- *One resource rail.* `features/workforce/useResources` is the single techs+subgroups+search/score source.
|
||
- *Tags = skill levels; jobs carry a minimum required.* Dispatch logic cost-optimizes: cheapest tech that
|
||
still clears the job's minimum skill tags.
|
||
- *A tech on pause turns their dispatch tickets red* (roster ↔ dispatch coupling).
|
||
- *Booking offers obey a policy* (min lead time, hour window, max/day, buffer, blackouts).
|
||
- **Gotchas.**
|
||
- In `lib/roster.js`, **`erp.list` must be sequential, not `Promise.all`** (ERPNext chokes), use
|
||
`http.request` (not a global `fetch`), and watch Pydantic `None` on the solver side + anti-over-staffing.
|
||
- The legacy osTicket → Dispatch Job bridge is **opt-in** (`LEGACY_DISPATCH_SYNC=on`), idempotent via
|
||
`legacy_ticket_id`, and **recreating the container ≠ restarting it** for env to take effect.
|
||
|
||
### 3.5 Field-tech mobile app (`/j/*`, `TechLayout`; hub `/t/<token>`, `/field`)
|
||
|
||
- **What.** A phone-first app techs reach via SMS magic-link: today's jobs, job detail + status updates,
|
||
camera **serial scan**, device lookup, modem/Wi-Fi diagnostics, offline queue.
|
||
- **Logic.** Routes under `/j` with a magic-link catch-all `/j/:token`. `useScanner` (camera → Gemini
|
||
Vision OCR), `useOfflineStore` (Pinia, queue writes when offline), `useModemDiagnostic`/`useWifiDiagnostic`
|
||
(signal thresholds → verdicts). The hub also serves a server-rendered SPA at `/t/<token>`.
|
||
- **Operating conditions.** Auth = the **JWT in the link is the credential** (no SSO on a tech's phone).
|
||
Devices are linked to **addresses, not customers** — preserve manage-IP/credentials/parent hierarchy.
|
||
- **Gotchas.** This is the part most people get wrong on mobile elsewhere in the app — see §5: the tech
|
||
view *is* mobile-designed; the **staff console pages are not** (that's the redesign work).
|
||
|
||
### 3.6 Network, devices, OLT, diagnostics (`/network`, `/olt`, `/devices`)
|
||
|
||
- **What.** Topology/dependency map, health, OLT signal stats, poller admin, AI log RCA.
|
||
- **Logic.**
|
||
- `devices.js` caches GenieACS devices (poller sweeps ~6,000 ONTs / 5 min).
|
||
- `olt-snmp.js` polls TP-Link (ent. 11863) and Raisecom (ent. 8886) GPON OLTs over SNMP every 5 min —
|
||
ONU table walk, signal/power/serial. WAN throughput (Mbps) is **live SNMP**, not Elasticsearch.
|
||
- `network-intel.js` feeds InfluxDB + Grafana logs to Gemini for root-cause analysis.
|
||
- `outage-monitor.js` turns Uptime-Kuma webhooks into synthesized alerts.
|
||
- **Operating conditions.** WAN live-rate graph works on **Raisecom OLTs (tech-2)** only; tech-3 shows
|
||
"indisponible". The OLT keys ONUs by the same TPLG format as ERPNext (direct match).
|
||
- **Gotchas.** **SNMP Counter64 arrives as an 8-byte Buffer** — read it with `readBigUInt64BE`, or you get
|
||
~0/null. WAN octet OIDs (Raisecom): `rx=…2.4.1.5.{oltId}.40`, `tx=…3.{oltId}.40`,
|
||
`oltId = slot*1e7 + port*1e5 + ontid`, `.40` = the internet VLAN.
|
||
|
||
### 3.7 Billing sync & financial reports (`/sync-legacy`, `/rapports/*`)
|
||
|
||
- **What.** The F→ERPNext sync dashboard + reports: revenue by account, detailed sales, **TPS/TVQ taxes**,
|
||
AR aging, "Internet cher" (overpaying addresses + competition), **negative recurring bills**, and
|
||
**terminated accounts with active services**.
|
||
- **Logic.** Reports hit ERPNext (`lib/reports.js`) or the legacy MariaDB (`lib/legacy-reports.js`).
|
||
Payment reconciliation (`lib/legacy-payments.js`) mirrors Payment Entries into ERPNext idempotently
|
||
(`PE-{id}`), guarded by `confirm:'F-WINS'`, and computes a billing-coherence status.
|
||
- **Operating conditions.**
|
||
- *Recurring monthly bill is never negative.* When it was, the root cause was a **status-mapping bug**
|
||
(`status=1 + date_suspended>0` wrongly mapped to "Suspended"), corrected to `status=1 ⇒ Actif` in both
|
||
the Python cron and the JS. (177 subscriptions corrected.) Report: `/rapports/factures-negatives`.
|
||
- *Terminating a customer does NOT cascade to deactivate their services.* Some terminated accounts keep
|
||
legitimately-active services (**employees with comped internet**). Cancellation is per-service. The
|
||
account "Résilié" status is **informational** (a cleanup-report column), never an automatic action.
|
||
Report: `/rapports/resilies-actifs` (~2,724 terminated accounts / 3,966 active services).
|
||
- *Before ERPNext ever emits real billing, validate balance against F first.* (Scheduler stays paused
|
||
until cutover.)
|
||
- **Gotchas.**
|
||
- **Never join `delivery.account_id`** in F — it's unindexed → full scan → freezes live billing. Query by
|
||
`delivery_id` (indexed). Drive delivery_ids from ERPNext PG, then query F by those.
|
||
- ERPNext v16 on Postgres has real bugs (GROUP BY/HAVING/boolean/double-quote/ORDER BY NULL) — patches
|
||
are baked into the image; custom-field columns sometimes need an `ALTER` via the pool.
|
||
|
||
### 3.8 Address intelligence (`/conformite-adresses`, `/serviceability`)
|
||
|
||
- **What.** Address search/autocomplete, conformity/normalization review (incl. camping sites), and
|
||
per-address **competitor lookup**.
|
||
- **Logic.** `address-db.js` owns the shared PG pool over `rqa_addresses` (5.24M) + `fiber_availability`,
|
||
with a phased trigram query. `serviceability.js` hits **Québec IHV ArcGIS open data** (providers by
|
||
address, incl. Cogeco) with a `Referer: quebec.ca` header — this replaced a reCAPTCHA-blocked checker.
|
||
- **Operating conditions.** Future targeting is by **service availability at an address / a specific active
|
||
service**, at the service/address level — not the customer level.
|
||
- **Gotchas.** Trigram `word_similarity` with `<%` has a perf cliff — phase the query. The whole address DB
|
||
is **local** now (no Supabase cloud).
|
||
|
||
### 3.9 Campaigns — gift cards (`/campaigns/*`)
|
||
|
||
- **What.** Email gift-card campaigns: 2 CSVs → match to ERPNext `Customer` → Mailjet blast of personalized
|
||
FR/EN emails carrying a **Giftbit** shortlink → live SSE progress; reminder campaign for non-clickers;
|
||
Unlayer template editor + AI FR↔EN translate.
|
||
- **Logic.** `campaigns.js` (2,389 lines): CSV parse/match, MJML compile, Mailjet send + Event webhook,
|
||
the gift **redirect wrapper** `/g/` (own expiry + reuse). `giftbit.js` is the gift-card API client.
|
||
- **Operating conditions.** Email routing is FR/EN by `Customer.language`. The gift `/g/` wrapper gives us
|
||
our own expiry/reuse control on top of Giftbit.
|
||
- **Gotchas.** A real incident: a `/campaigns` endpoint was **public without auth** → "card already used" /
|
||
"$0.03 balance" (cards drained). Locked by token. Giftbit defaults to **TESTBED** (no real cards) until a
|
||
separate prod token is wired. Map-CSV customer matching only resolves ~25% — still a production blocker.
|
||
|
||
### 3.10 Customer-facing (portal, checkout, contracts, diag, store)
|
||
|
||
- **What.** Passwordless customer portal, public checkout/catalog/order, contract acceptance, a self-service
|
||
live diagnostic page, a hardware store (staging), self-signup (staging).
|
||
- **Logic.** `portal-auth.js` (email/phone → 24h JWT), `checkout.js`, `contracts.js` (residential JWT +
|
||
commercial DocuSeal), `client-diag.js` (`/diag/<jwt>` shows what works, no internal data), `payments.js`
|
||
(Stripe + webhook + **daily PPA cron** 06:00 EST).
|
||
- **Operating conditions.**
|
||
- Legacy passwords are **unsalted MD5** → force reset via email/SMS OTP, never trust them.
|
||
- The `/diag` page must **never leak internal data**.
|
||
- Contract acceptance is two-track: residential = JWT (promotion framing), commercial = DocuSeal.
|
||
- CRTC 2026-43 (in force 2026-06-12): install is billable (exception), termination fees forbidden without
|
||
a subsidized device; the residential "promotion spread/clawback" framing is now non-compliant.
|
||
- **Gotchas.** Invoice PDFs carry a **QR code** to the Stripe/portal payment link.
|
||
|
||
### 3.11 Telephony & voice agent (`/telephony`, `/voice/*`)
|
||
|
||
- **What.** 3CX telephony admin + a WebRTC softphone in the SPA (`usePhone`, SIP.js over WSS) + an **AI
|
||
voice agent** over Twilio Media Streams ↔ Gemini Live.
|
||
- **Logic.** `pbx.js` polls the 3CX call log + handles call-event webhooks; `voice-agent.js` does
|
||
mulaw↔PCM transcoding and bridges Twilio audio to Gemini Live.
|
||
- **Gotchas.** Voice agent is the only WebSocket consumer (`/voice/ws`). Inbound `/voice/*` is public.
|
||
|
||
### 3.12 Admin / RBAC / settings (`/settings`, `/equipe`, `/agent-flows`)
|
||
|
||
- **What.** Users, **permission matrix**, user groups, legacy-sync controls, AI agent flow/prompt editor.
|
||
- **Logic.** RBAC in `lib/auth.js` (capabilities like `view_clients`, `assign_jobs`, …) over Authentik;
|
||
the SPA gates nav items and features by capability (`usePermissions`). Nav is data-driven from
|
||
`src/config/nav.js` (each item has a `requires`).
|
||
- **Operating conditions.** SSO is **Authentik** (`auth.targo.ca`) — which is also why the staff app
|
||
**cannot be loaded headlessly** for verification (see §4).
|
||
|
||
---
|
||
|
||
## 4. Cross-cutting gotchas — "if you build another software, know these"
|
||
|
||
These are the lessons that generalize beyond TARGO.
|
||
|
||
**Legacy / data integrity**
|
||
- **Treat the authoritative legacy system as read-only and fragile.** One connection at a time, only
|
||
indexed queries, never the expensive join. A reporting query must never be able to freeze billing.
|
||
- **Keep the foreign keys.** Embedding `legacy_service_id` in the mirror's record *name* saved us; losing a
|
||
correlation key is expensive to rebuild.
|
||
- **A mirror lag is not a business event.** Separate "the mirror disagrees" from "the customer changed."
|
||
Auto-acting on the former manufactures fake churn.
|
||
- **Derive status, don't sync it.** `disabled`/`is_suspended` should be computed from services, never
|
||
flipped from the source — and cancellation belongs at the service level, not the account level
|
||
(employees with comped service prove the account-level cascade wrong).
|
||
- **An invariant deserves a guard *and* a report.** "Recurring bill ≥ 0" got both a fiche-level red warning
|
||
and a `/rapports/factures-negatives` sweep. The report found the systemic mapping bug the guard couldn't.
|
||
|
||
**ERPNext v16 on PostgreSQL specifically**
|
||
- It ships SQL that assumes MySQL (GROUP BY/HAVING/boolean/double-quotes/ORDER BY NULL). Patch in-image and
|
||
make patches idempotent. `get_list` silently rejects some fields → wrap with a field-drop-retry.
|
||
- Keep the scheduler paused and `mute_emails` on until you actually own billing.
|
||
|
||
**Realtime / frontend architecture**
|
||
- **Find your single points of failure.** One component (`ConversationPanel`) owning the init + the drawer +
|
||
the badge + the compose dialog means one `setup()` throw blacks out an entire subsystem while the rest of
|
||
the app looks fine. Either spread the init or know the symptom signature cold.
|
||
- **TDZ in `<script setup>`** — a top-level `watch(x)`/eager call before `const x = …` throws at runtime and
|
||
the build won't catch it. Order matters; computeds/functions are lazy, watches are not.
|
||
- **You can't verify an SSO-gated SPA headlessly.** Plan for it: deterministic fixes + asking for the browser
|
||
console error are faster than fighting the auth wall. Don't blame the cache before ruling out a mount throw.
|
||
- **Cache after deploy.** Hashed assets persist; serve `index.html` `no-cache` so redeploys take effect
|
||
without a manual hard-refresh.
|
||
|
||
**Integration plumbing**
|
||
- **SNMP Counter64 = 8-byte Buffer** → `readBigUInt64BE`. Vendor enterprise OIDs differ (TP-Link vs Raisecom).
|
||
- **Auth via same-origin token-injecting proxy**, not forward-auth alone (cross-origin + SSE break).
|
||
- **Sequential, not parallel** for fragile upstreams (`erp.list` in the roster); `Promise.all` can DoS them.
|
||
- **Opt-in schedulers + idempotency keys** for anything that writes (the osTicket bridge); and remember
|
||
*recreate ≠ restart* for env changes.
|
||
- **Lock every public endpoint that can spend money** (the Giftbit incident). Default-deny + an
|
||
`ALWAYS_ENFORCE` list for the routes that should never see anonymous traffic.
|
||
|
||
**Ops/SSH hygiene**
|
||
- macOS has no `timeout`/`sudo` to the legacy box; use `su -s /bin/bash www-data -c …` and
|
||
`ssh -o BatchMode=yes -o ServerAliveInterval=…`. Back up before editing (`*.bak-YYYYMMDD`).
|
||
|
||
---
|
||
|
||
## 5. Mobile & code health (pointer)
|
||
|
||
The current state, the clean **mobile-first design system**, the per-page responsive strategy, and the
|
||
prioritized **code-optimization backlog** (helper consolidation, `<StatusBadge>`/`<StatCard>`, token
|
||
unification `--sb-*`→`--ops-*`, mega-component splits) live in a companion doc:
|
||
|
||
→ **`docs/UI_AND_OPTIMIZATION.md`**
|
||
|
||
One-line summary: the *shell* is responsive (drawer breakpoint 1024 + mobile header), but **no content page
|
||
is** (zero `q-table` grid mode, fixed-width dialogs/columns, `$q.screen` used in 2 files). The fix is a small
|
||
set of responsive primitives applied page-by-page, plus finishing the stalled helper consolidation
|
||
(`useFormatters` reaches 17 files, `useStatusClasses` only 4, while ~25 pages carry private copies).
|
||
|
||
---
|
||
|
||
## 6. Glossary
|
||
|
||
| Term | Meaning |
|
||
|---|---|
|
||
| **F** | `gestionclient` — the legacy PHP/MariaDB billing system. Authoritative for billing. |
|
||
| **hub** | `services/targo-hub` — the Node integration service (port 3300). |
|
||
| **OPS** | the Quasar staff SPA (`apps/ops`, `/opt/ops-app`). |
|
||
| **SUB-000…** | `Service Subscription` doc; the trailing number = F `service.id`. |
|
||
| **hijack** | per-service price override in F (`hijack_price`, `hijack_desc`). |
|
||
| **queue / file** | inbox label = department (Facturations, Service à la clientèle, Supports, Technicien). |
|
||
| **dostuff** | F's n8n webhook for live modem status (`tech=3`). |
|
||
| **RQA** | the local Québec address database (`rqa_addresses`). |
|
||
| **PPA** | pre-authorized payment (daily Stripe cron). |
|
||
| **TPLG serial** | ERPNext-side ONU serial format; ≠ GenieACS real serial (MAC fallback). |
|