# 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/`. | 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 `