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