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

29 KiB
Raw Blame History

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.idand 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 buildscp 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/adminAuthorization: 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 BufferreadBigUInt64BE. 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).