gigafibre-fsm/docs/PLATFORM_GUIDE.md
louispaulb 5ab14cac44 feat(fsm): platform build — comms UI, F→ERPNext sync/billing, roster, campaigns, network, reports
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>
2026-06-15 06:12:12 -04:00

396 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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). |