gigafibre-fsm/docs/features/customer-portal.md
louispaulb 30a867a326 fix(tech): restore Gemini-native scanner + port equipment UX into ops
The ops tech module at /ops/#/j/* had drifted from the field app in two ways:

1. Scanner — a prior "restoration" re-added html5-qrcode, but the
   design has always been native <input capture="environment"> → Gemini
   2.5 Flash via targo-hub /vision/barcodes (up to 3 codes) and
   /vision/equipment (structured labels, up to 5). Revert useScanner.js
   + ScanPage.vue + TechScanPage.vue to commit e50ea88 and drop
   html5-qrcode from both package.json + lockfiles. No JS barcode
   library, no camera stream, no polyfills.

2. Equipment UX — TechJobDetailPage.vue was a 186-line stub missing the
   Ajouter bottom-sheet (Scanner / Rechercher / Créer), the debounced
   SN-then-MAC search, the 5-field create dialog, Type + Priority
   selects on the info card, and the location-detail contact expansion.
   Port the full UX from apps/field/src/pages/JobDetailPage.vue (526
   lines) into the ops module (458 lines after consolidation).

Rebuilt and deployed both apps. Remote smoke test confirms 0 bundles
reference html5-qrcode and the new TechJobDetailPage.1075b3b8.js chunk
(16.7 KB vs ~5 KB stub) ships the equipment bottom-sheet strings.

Docs:

- docs/features/tech-mobile.md — new. Documents all three delivery
  surfaces (legacy SSR /t/{jwt}, transitional apps/field/, unified
  /ops/#/j/*), Gemini-native scanner pipeline, equipment UX, magic-link
  JWT, cutover plan. Replaces an earlier stub that incorrectly
  referenced html5-qrcode.
- docs/features/dispatch.md — new. Dispatch board, scheduling, tags,
  travel-time optimization, magic-link SMS, SSE updates.
- docs/features/customer-portal.md — new. Plan A passwordless magic-link
  at portal.gigafibre.ca, Stripe self-service, file inventory.
- docs/architecture/module-interactions.md — new. One-page call graph
  with sequence diagrams for the hot paths.
- docs/README.md — expanded module index (§2) now lists every deployed
  surface with URL + primary doc + primary code locations (was missing
  dispatch, tickets, équipe, rapports, telephony, network, agent-flows,
  OCR, every customer-portal page). New cross-module edge map in §4.
- docs/features/README.md + docs/architecture/README.md — cross-link
  all new docs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:56:38 -04:00

708 lines
40 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.

# Customer Portal — Architecture & Integration Guide
> Authoritative reference for the Gigafibre customer portal: the Vue 3 +
> Quasar SPA at `portal.gigafibre.ca`, its passwordless magic-link
> authentication, and the standalone `nginx:alpine` container that serves
> it. Companion document to [../architecture/overview.md](../architecture/overview.md)
> and [../architecture/data-model.md](../architecture/data-model.md).
>
> Last updated: 2026-04-22. Plan A (magic-link login replacing ERPNext
> `/login`) shipped today. See commits `2b04e6b` and `7ac9a58` for the
> deploy-topology cutover.
---
## 1. Goals & Principles
The customer portal is the only front door a residential or commercial
customer ever sees. It replaces the legacy PHP portal and the ERPNext
`/login` password form; staff continue to use Authentik (`ops.gigafibre.ca`
/ `erp.gigafibre.ca`) and never touch the portal host.
Design tenets:
1. **Passwordless by default.** The customer types an email or phone, the
hub mails/SMSes a 24 h JWT, the SPA decodes it client-side. No password,
no session cookie, no Authentik for customers — rationale documented in
`~/.claude/projects/-Users-louispaul-Documents-testap/memory/project_portal_auth.md`
(legacy passwords are unsalted MD5 and cannot be trusted).
2. **Anti-enumeration by contract.** `POST /portal/request-link` *always*
returns `HTTP 200 {"ok":true, "sent":[…]}`. An unknown identifier returns
`sent: []`. An attacker cannot use the endpoint to probe whether an email
or phone is in our customer database.
3. **Rate limit the send surface.** 3 requests per 15-minute window per
identifier (case-insensitive). Survives restart loss gracefully — the
Map is in-memory and repopulates on the next abuse attempt.
4. **Both channels, best effort.** A customer with both email and phone
gets the link on both — SMS failure does not block email, and vice versa.
The `sent: [...]` array tells the UI which channels actually delivered
so it can display redacted destinations.
5. **Two routes, one SPA.** `portal.gigafibre.ca` is the canonical host.
`client.gigafibre.ca` is a 307 redirect at the Traefik layer so stale
bookmarks, SMS notifications from before the cutover, and old invoice
links still land on the right place.
6. **Hash router, not path router.** The SPA uses `createWebHashHistory`
so the token survives behind the `#` and never reaches nginx. Also lets
the single `index.html` serve every route without per-path nginx rules.
7. **Retire, don't migrate.** ERPNext's `/login` is dead for customers —
there is no "staff impersonation" path in the passwordless flow. Staff
who need to act as a customer use the Ops "Send magic link" action on
the client-detail page (implemented via `POST /portal/request-link`).
---
## 2. Topology & Deployment
### 2.1 Hosts & routing
```
┌───────────────────────────────────────┐
│ Traefik (erp.gigafibre.ca:443) │
│ *.gigafibre.ca wildcard → 96.125.196.67│
└───────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌───────────────────────┐ ┌────────────────────┐ ┌──────────────┐
│ portal.gigafibre.ca │ │ client.gigafibre.ca│ │ erp.*/ops.* │
│ (canonical host) │ │ (legacy alias) │ │ (staff) │
├───────────────────────┤ ├────────────────────┤ ├──────────────┤
│ docker container │ │ Traefik dynamic │ │ ERPNext │
│ client-portal │ │ route — 307 to │ │ frontend + │
│ image nginx:alpine │ │ portal.gigafibre.ca│ │ Authentik │
│ bind-mounts │ │ /$1 (preserves │ │ forwardAuth │
│ /opt/client-app/ │ │ path) │ │ │
│ ─ index.html │ │ │ │ NO customer │
│ ─ assets/* │ │ (traefik-client- │ │ /login form │
│ ─ nginx.conf │ │ portal.yml) │ │ — endpoint is│
│ ─ docker-compose │ │ │ │ blocked by │
└───────────────────────┘ └────────────────────┘ │ redirect │
└──────────────┘
│ magic-link JWT + REST
│ (msg.gigafibre.ca)
┌──────────────────┐
│ targo-hub │
│ portal-auth.js │
│ magic-link.js │
└──────────────────┘
│ resource REST
┌──────────────────┐
│ ERPNext backend │
│ (Customer, Sales │
│ Invoice, Issue) │
└──────────────────┘
```
### 2.2 Two distinct Vue builds used to coexist — no more
Before 2026-04-22 the same Vue SPA was built twice: once with
`base=/assets/client-app/` and dropped into the ERPNext public folder so
ERPNext's frontend would serve it at `client.gigafibre.ca/assets/client-app/`;
a second build ran for local dev. That's retired — we now build with
`base=/` and ship to a standalone nginx container.
`apps/client/quasar.config.js` keeps `/assets/client-app/` as the fallback
so a local `quasar dev` still works, but production always overrides with
`DEPLOY_BASE=/`:
```js
// apps/client/quasar.config.js
extendViteConf (viteConf) {
viteConf.base = process.env.DEPLOY_BASE || '/assets/client-app/'
}
```
```bash
# apps/client/deploy.sh (prod build)
VITE_ERP_TOKEN="…" DEPLOY_BASE=/ npx quasar build -m pwa
```
### 2.3 `deploy.sh` — atomic swap + the inode gotcha
The deploy does three things remotely:
1. Upload the fresh build into a staging directory (`mktemp -d
/opt/client-app.new.XXXXXX`).
2. `mv` the live `/opt/client-app` aside (`/opt/client-app.bak.<epoch>`),
then `mv` the staging dir onto `/opt/client-app`. This is atomic —
nginx never sees a half-written `index.html` referencing hashed assets
that haven't finished uploading.
3. Keep the three most recent backups, delete older ones.
Then the non-obvious part:
> **Docker bind mounts follow the inode, not the path.** When `docker run`
> records a bind mount, it resolves `/opt/client-app` once and pins that
> inode. After the atomic `mv` swap, the container is still pointed at the
> *old* directory (now `/opt/client-app.bak.<epoch>`) — so you'd serve
> stale content from the backup until you recreate the mount.
>
> Fix: `docker restart client-portal` at the end of the deploy. The
> restart re-resolves the bind and the container picks up the new inode.
That `docker restart` line is the entire reason `deploy.sh` needed an
update today; previously we relied on the bind mount "just reflecting the
new files" which only works when you edit them in place, not when you
swap the entire directory.
### 2.4 Local build only
```bash
./deploy.sh local # build with DEPLOY_BASE=/ into dist/pwa/, no upload
```
Handy when you want to test the PWA manifest / service worker path
behaviour before shipping.
---
## 3. File Inventory
### 3.1 Front-end — `apps/client/`
| Path | Responsibility |
|--------------------------------------------------|--------------------------------------------------------------------------|
| `deploy.sh` | Build + rsync + atomic swap + `docker restart client-portal` |
| `quasar.config.js` | Vite base override (`DEPLOY_BASE`), hash router, PWA manifest |
| `src/App.vue` | Root — calls `store.init()` once on mount |
| `src/router/index.js` | Routes + navigation guard (pushes unauthenticated to `/login`) |
| `src/layouts/PortalLayout.vue` | Drawer + header + cart badge, shows customer name |
| `src/stores/customer.js` | Pinia store: `hydrateFromToken`, JWT decode, `stripTokenFromUrl`, `init` |
| `src/stores/cart.js` | Pinia store for the public catalog cart |
| `src/composables/useMagicToken.js` | Read-through: `{authenticated, expired, customerId}` for payment pages |
| `src/composables/useSSE.js` | EventSource wrapper for `msg.gigafibre.ca/sse` (topic-based) |
| `src/composables/useFormatters.js` | `formatDate`, `formatMoney`, `formatShortDate`, `formatPrice` |
| `src/composables/useOTP.js` | OTP send/verify for catalog checkout existing-customer path |
| `src/composables/useAddressSearch.js` | RQA address autocomplete wrapper |
| `src/composables/usePolling.js` | Generic polling helper |
| `src/api/auth-portal.js` | `requestPortalLink()` — posts to hub `/portal/request-link` |
| `src/api/auth.js` | `authFetch()` wraps ERPNext REST with service token (`VITE_ERP_TOKEN`) |
| `src/api/portal.js` | ERPNext REST client: invoices, tickets, profile, service locations, comms |
| `src/api/payments.js` | Hub client: balance, methods, checkout, setup card, billing portal, PPA |
| `src/api/catalog.js` | Hub client: catalog, checkout, address search, OTP |
| `src/pages/*.vue` | One page per route (see §4) |
### 3.2 Back-end — `services/targo-hub/lib/`
| Path | Responsibility |
|----------------------|--------------------------------------------------------------------------------------|
| `portal-auth.js` | `POST /portal/request-link` — lookup, mint JWT, dispatch SMS + email, rate limit |
| `magic-link.js` | `generateCustomerToken()` — HS256 JWT with `scope=customer`, 24 h TTL |
| `config.js` | `CLIENT_PORTAL_URL`, `JWT_SECRET` — wired to `portal.gigafibre.ca` by default |
| `helpers.js` | `lookupCustomerByPhone()` — last-10-digit match across `cell_phone`, `tel_home`, `tel_office` |
| `twilio.js` | `sendSmsInternal()` — used for the SMS channel |
| `email.js` | `sendEmail()` — used for the email channel |
| `checkout.js` / `payments.js` | Stripe endpoints that the portal hits from `InvoiceDetailPage` |
| `conversation.js` | SSE topic `conversations` + `customer:<id>` for real-time inbox updates |
### 3.3 Traefik — `apps/portal/`
| Path | Responsibility |
|------------------------------|----------------------------------------------------------------------------|
| `traefik-client-portal.yml` | Dynamic route: `Host(\`client.gigafibre.ca\`)` → 307 to `portal.…/$1` |
| `deploy-portal.sh` | `scp` the YAML to `/opt/traefik/dynamic/client-portal.yml` + smoke-test |
---
## 4. Routes & Pages
The router uses `createWebHashHistory`, so every URL above the portal
root looks like `https://portal.gigafibre.ca/#/<path>`. Route paths in
French match the customer-facing UI vocabulary (`/me`, `/panier`,
`/paiement/merci`, `/commande/confirmation`).
| Path | Name | Purpose | Endpoints |
|-------------------------------|---------------------|---------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `/login` | `login` | Passwordless login form — email or phone input, sent state, rate banner | hub `POST /portal/request-link` |
| `/` | `dashboard` | "Bonjour, {name}" + unpaid count + open tickets + 5 most recent invoices | ERPNext `Sales Invoice`, `Issue`; hub `POST /payments/checkout` |
| `/invoices` | `invoices` | Paginated table of Sales Invoices with download PDF action | ERPNext `Sales Invoice`, `frappe.utils.print_format.download_pdf` |
| `/invoices/:name` | `invoice-detail` | Single invoice: summary, lines, taxes, pay CTA (card + Klarna), PDF | ERPNext `Sales Invoice/:name`, `printview.get_html_and_style`; hub `POST /payments/checkout-invoice` |
| `/tickets` | `tickets` | Support ticket list + create dialog | ERPNext `Issue` (list + create) |
| `/tickets/:name` | `ticket-detail` | Conversation thread + reply box | ERPNext `Issue/:name`, `Communication` (list + post) |
| `/messages` | `messages` | All communications (SMS / Email / Phone) grouped by ticket or medium | ERPNext `Communication` (direct + via Issues); hub SSE topic `customer:<id>` |
| `/me` | `account` | Customer info, service locations + monthly total, saved cards, PPA toggle | ERPNext `Customer/:name`, `Service Location`, `Service Subscription`; hub `/payments/balance`, `/payments/methods`, `/payments/setup`, `/payments/portal`, `/payments/toggle-ppa` |
| `/catalogue` | `catalog` | Public add-on catalog (Internet / Téléphonie / Bundle / Équipement) | hub `GET /api/catalog` |
| `/panier` | `cart` | Cart review, install scheduling, OTP flow for existing customers | hub `POST /api/otp/send`, `POST /api/otp/verify`, `POST /api/address-search`, `POST /api/checkout` |
| `/commande/confirmation` | `order-success` | Post-checkout confirmation with order number + install notice | hub `GET /api/order/:id` |
| `/paiement/merci` | `payment-success` | Stripe return (session mode) — "Paiement reçu" + link back to invoice | — (return landing only) |
| `/paiement/annule` | `payment-cancel` | Stripe return when the user backs out | — (return landing only) |
| `/paiement/carte-ajoutee` | `payment-card-added`| Stripe Setup Intent return — confirms the card is saved, nudges PPA | — (return landing only) |
### 4.1 Public routes (skip the nav guard)
```js
// apps/client/src/router/index.js
const PUBLIC_ROUTES = new Set([
'login',
'catalog', 'cart', 'order-success',
'payment-success', 'payment-cancel', 'payment-card-added',
])
```
Payment returns stay public so a customer whose JWT has expired still
sees a human confirmation (plus a re-auth CTA) rather than a bounce.
The catalog and cart are public so unauthenticated prospects can shop
add-ons — the OTP flow in the cart escalates to auth only at checkout.
---
## 5. Authentication Flow
### 5.1 Sequence
```
Customer Login Page targo-hub ERPNext Email/SMS
│ │ │ │ │
│── types email/phone ──▶ │ │ │
│ │── POST /portal/ │ │ │
│ │ request-link ─────────▶│ │ │
│ │ │── lookupCustomer ─────▶│ │
│ │ │◀── Customer row ───────│ │
│ │ │ │
│ │ │── generateCustomerToken (HS256, 24h) ───────│
│ │ │── sendSmsInternal ─────────────────────────▶│
│ │ │── sendEmail ───────────────────────────────▶│
│ │◀── 200 {ok:true, │ │ │
│ │ sent:[sms,email], │ │ │
│ │ redacted:[…]} ────────│ │ │
│◀── "Lien envoyé!" ────│ │ │ │
│ │
│ (customer opens SMS / email) │
│◀───────────────────── https://portal.gigafibre.ca/#/?token=<JWT> ──────────────────────────────│
│ │
│── click ──▶ SPA loads │
│ └─ router guard: to.query.token → store.hydrateFromToken() │
│ └─ JWT parts[1] base64-decoded, exp checked, scope==='customer' │
│ └─ customerStore.customerId = payload.sub │
│ └─ stripTokenFromUrl() (history.replaceState) │
│ └─ dashboard renders │
```
### 5.2 JWT shape
Minted by `generateCustomerToken(customerId, customerName, email, ttlHours=24)`
in `services/targo-hub/lib/magic-link.js`:
```json
{
"alg": "HS256", "typ": "JWT"
}
.
{
"sub": "C-00042",
"scope": "customer",
"name": "Jean Tremblay",
"email": "jean@example.com",
"iat": 1713798000,
"exp": 1713884400
}
```
Signed HS256 with `cfg.JWT_SECRET` (shared only between the hub processes
— the portal never holds the secret). The portal trusts the signature
implicitly because the token arrived over HTTPS via a channel the user
already controls (their own inbox or SMS). Any tampering breaks
signature verification the next time the user clicks the link; the
currently-loaded SPA never re-verifies.
### 5.3 Identifier resolution
`portal-auth.js → lookupCustomer(raw)` handles three forms:
| Input shape | Lookup order |
|------------------------|-----------------------------------------------------------------------------------------------|
| Contains `@` | `Customer.email_id``Customer.email_billing` (both lowercased) |
| Matches `^(C-|CUST-|GI-)/i` or `^\d{4,7}$` | `Customer.legacy_customer_id` → direct `Customer.name` fetch |
| Anything else (phone) | `helpers.lookupCustomerByPhone()` — last 10 digits matched against `cell_phone`/`tel_home`/`tel_office` |
Returned record always includes `name`, `customer_name`, `cell_phone`,
`tel_home`, `email_id`, `email_billing` so the sender knows which channels
are available.
### 5.4 URL hygiene after hydration
`stores/customer.js → stripTokenFromUrl()` uses `history.replaceState` to
rewrite the URL without `?token=` as soon as the store is hydrated. This
keeps the token out of:
- Browser history (back button won't show the token).
- Bookmarks ("add this page to bookmarks" captures the stripped URL).
- Third-party referer headers (external links on subsequent pages).
The JWT still lives in the Pinia store for the rest of the tab's life;
a hard refresh reloads the store from…nothing, and the router guard
bounces back to `/login` unless the URL carries a token or a previous
hydration put `customerId` into `localStorage` (the store does not
persist — this is intentional; a refresh is a fresh auth attempt).
### 5.5 Subsequent calls
Two backends, two auth styles:
- **ERPNext REST** (`BASE_URL/api/resource/…`, `/api/method/…`): served
through `authFetch()` in `src/api/auth.js`, which attaches
`Authorization: token <VITE_ERP_TOKEN>`. The token is a fixed service
key baked in at build time; it's not the customer's JWT. The customer's
identity is enforced at the query layer by always filtering on
`customer = store.customerId` from the store.
- **Hub** (`msg.gigafibre.ca/payments/*`, `/api/*`, `/sse`): currently
called unauthenticated. Endpoints that need customer context receive
`customer` as a body/path parameter. The hub trusts the portal origin.
Upgrading these endpoints to verify the magic-link JWT is tracked as
a hardening follow-up (see §10).
---
## 6. Anti-Enumeration Contract
`POST /portal/request-link` adheres strictly to the following response
matrix. Callers (portal UI, staff-ops tools, any future integration)
must treat `200 OK` as the *only* success signal and must *not* branch
on the identity of sent channels to decide whether the account exists.
| Situation | HTTP | Body |
|-----------------------------------|------|---------------------------------------------------------------------------------|
| Unknown identifier | 200 | `{"ok":true, "sent":[]}` |
| Known, only email on file | 200 | `{"ok":true, "sent":["email"], "redacted":["j***n@example.com"]}` |
| Known, only phone on file | 200 | `{"ok":true, "sent":["sms"], "redacted":["***-***-1234"]}` |
| Known, both channels | 200 | `{"ok":true, "sent":["sms","email"], "redacted":["***-***-1234","j***n@…"]}` |
| Known, both channels, both failed | 200 | `{"ok":true, "sent":[]}` (logged internally as delivery failure) |
| Empty body | 400 | `{"error":"identifier_required"}` |
| Rate limit hit | 429 | `{"error":"rate_limit", "message":"Trop de tentatives. Réessayez dans N min.", "retry_after_sec":N}` |
Two downstream rules follow:
1. **The login page shows "Lien envoyé"** even on `sent: []`. The copy
says "Si votre compte existe, un lien vous a été envoyé" — the timing
and the UI are identical to the happy path. A typo'd email looks
indistinguishable from a genuine send.
2. **The internal log still records the miss** (`log('Portal
request-link: no match for "…"')`) so ops can spot fat-finger traffic
vs. probing. The log is not served publicly.
Redaction rules (`redactEmail`, `redactPhone`):
- Email: keep first + last char of local part, mask the middle, keep
the domain (`j***n@example.com`).
- Phone: `***-***-XXXX` where `XXXX` is the last 4 digits of the raw
`cell_phone`/`tel_home` field (not the E.164-normalized one).
---
## 7. Rate Limiting
A single `Map<identifier, {count, windowStart}>` in the hub process
enforces 3 requests per 15 minutes (`RATE_LIMIT_MAX`, `RATE_LIMIT_WINDOW_MS`
in `portal-auth.js`). The key is `rawIdentifier.trim().toLowerCase()` so
`jean@example.com` and `JEAN@Example.com` share a bucket.
Lifecycle:
```js
// portal-auth.js
function checkRateLimit (key) {
const now = Date.now()
const entry = rateLimits.get(key)
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
rateLimits.set(key, { count: 1, windowStart: now })
return { ok: true }
}
if (entry.count >= RATE_LIMIT_MAX) {
return { ok: false, retryAfterMs: RATE_LIMIT_WINDOW_MS - (now - entry.windowStart) }
}
entry.count++
return { ok: true }
}
// Cull every 15 min so the Map stays small
setInterval(() => { /* drop entries older than window */ }, RATE_LIMIT_WINDOW_MS).unref()
```
### 7.1 Failure modes we accept
- **Process restart** wipes the Map. Worst case a throttled attacker gets
their window reset — still bounded by Twilio + Mailjet rate limits, and
the internal `Portal request-link` log surfaces the abuse for ops.
- **Hub horizontal scale** would duplicate the Map per replica. Today the
hub is a single container so the Map is authoritative; if we scale out,
move to Redis (tracked under the hub hardening line in
[../roadmap.md](../roadmap.md)).
- **Identifier rotation** (attacker types slightly different strings):
mitigated by Twilio/Mailjet per-destination throttles that kick in
downstream — the attacker can't out-run our carrier tier.
### 7.2 Error surfacing to the UI
`requestPortalLink()` rethrows the 429 as a typed error:
```js
// src/api/auth-portal.js
if (r.status === 429) {
const err = new Error(data.message || 'Trop de tentatives. Réessayez plus tard.')
err.code = 'rate_limit'
err.retryAfterSec = data.retry_after_sec || 900
throw err
}
```
`LoginPage.vue` displays the message in an orange banner (`q-banner
bg-orange-1`) without clearing the input — the user can read the message
and adjust their identifier.
---
## 8. Cross-Module References
### 8.1 Billing — Stripe Checkout + Klarna
`InvoiceDetailPage.vue` exposes both payment rails:
```js
payInvoice('card') hub POST /payments/checkout-invoice { payment_method: 'card' }
payInvoice('klarna') hub POST /payments/checkout-invoice { payment_method: 'klarna' }
```
The hub returns a hosted Checkout URL; the SPA does
`window.location.href = url`. Stripe's return URLs come back to
`/#/paiement/merci?invoice=…`, `/#/paiement/annule?invoice=…`, and
`/#/paiement/carte-ajoutee` (Setup Intent flow). All three live under
`PUBLIC_ROUTES` so an expired token still gets a civilized landing.
Full lifecycle: [billing-payments.md](billing-payments.md) — especially
§4 "Flux de paiement public" and §7 "Configuration & secrets" for Stripe
keys and webhook wiring.
### 8.2 Messaging — Server-Sent Events
`MessagesPage.vue` subscribes to `msg.gigafibre.ca/sse?topics=customer:<id>`
via `useSSE`. The hub broadcasts `conversations` and per-customer topics
from `lib/conversation.js` (see lines 6870 of that file); every inbound
SMS or email handled by the hub fires a broadcast, and the portal
reloads `fetchAllCommunications` on receipt and shows a toast for
inbound-direction messages.
The SPA's SSE connection is resilient: `useSSE` implements exponential
backoff (1 s → 30 s) on disconnect. Read path details in
`apps/client/src/composables/useSSE.js`.
### 8.3 Tickets — read-only window into ERPNext HD Ticket
`TicketsPage` and `TicketDetailPage` hit the stock ERPNext `Issue`
doctype. Creation goes through `POST /api/resource/Issue` with
`customer`, `subject`, `description`, `issue_type='Support'`,
`priority='Medium'`. Replies from the portal create a `Communication`
tied to the Issue; staff see them in the Ops CRM pane. Cross-ref
`docs/architecture/data-model.md` for the full `Issue` field list.
### 8.4 Catalog — shared SKUs with the quote wizard
`CatalogPage` lists the same Items the sales wizard exposes — Internet
plans, phone service, bundles, equipment. Prices and `requires_visit`
flags come from ERPNext Items via the hub's `/api/catalog` endpoint.
Add-on purchases placed through `/panier` become `Sales Order` drafts
that the dispatch queue picks up. See `billing-payments.md` §2
"App Frappe custom — `gigafibre_utils`" for the shared SKU catalog
source of truth.
### 8.5 Contract acceptance
Two separate tracks, both documented elsewhere:
- **Residential JWT** (promotion framing, 24 h link, same style as this
portal's auth) — `services/targo-hub/lib/contracts.js`. See
`billing-payments.md` and memory `project_contract_acceptance.md`.
- **Commercial DocuSeal** — `services/targo-hub/lib/acceptance.js`,
documented in the same memory file.
Neither track reuses the portal's magic-link JWT today; the
`scope=customer` token is strictly for browsing. A signed contract
produces its own short-lived acceptance JWT minted by `contracts.js`.
### 8.6 Tech mobile app
The field tech interface lives at `msg.gigafibre.ca/t/<tech-token>`
— a separate HS256 token scope (`scope='all'` or per-job) minted by
`magic-link.js`. It shares the hub's HS256 primitive (`signJwt`) with
the customer portal but not the token itself. There is no customer-facing
touchpoint on the tech app; cross-ref is kept here only so future
refactors remember the two scopes sign with the same secret.
---
## 9. Deploy & Operations
### 9.1 Ship a portal change
```bash
cd apps/client
./deploy.sh # build PWA + rsync + flip + docker restart
```
What the script prints on success:
```
Deployed. Backup: /opt/client-app.bak.1713798123
Done! Customer Portal: https://portal.gigafibre.ca/
Legacy alias (302): https://client.gigafibre.ca/
```
### 9.2 Ship a Traefik redirect change (`client.gigafibre.ca`)
```bash
cd apps/portal
./deploy-portal.sh # scp YAML to /opt/traefik/dynamic/ + smoke test
```
Traefik auto-reloads anything under `/opt/traefik/dynamic/` — no restart.
The script also curls `https://client.gigafibre.ca/login` and prints the
HTTP code so you know whether Let's Encrypt has provisioned the cert yet
(first request may return 000 while the cert is being issued).
### 9.3 Roll back
```bash
ssh root@96.125.196.67
ls -dt /opt/client-app.bak.* # pick the timestamp you want
mv /opt/client-app /opt/client-app.broken
mv /opt/client-app.bak.<epoch> /opt/client-app
docker restart client-portal # same inode trap as deploy
```
`deploy.sh` keeps the three most recent backups (`ls -dt … | tail -n +4
| xargs -r rm -rf`), which is usually enough to roll back a same-day mistake.
### 9.4 Smoke tests
```bash
# SPA shell reachable
curl -sI https://portal.gigafibre.ca/ | head -5
# Legacy redirect preserves path
curl -sI https://client.gigafibre.ca/invoices | grep -iE 'location|http/'
# expect: HTTP/2 307
# expect: location: https://portal.gigafibre.ca/invoices
# Magic-link endpoint: anti-enumeration contract
curl -s -X POST https://msg.gigafibre.ca/portal/request-link \
-H 'Content-Type: application/json' \
-d '{"identifier":"does-not-exist@example.com"}'
# expect: {"ok":true,"sent":[]}
# Rate limit (send the same identifier 4 times quickly)
for i in 1 2 3 4; do
curl -s -X POST https://msg.gigafibre.ca/portal/request-link \
-H 'Content-Type: application/json' \
-d '{"identifier":"rl-test@example.com"}' ; echo
done
# expect 4th call: 429 + rate_limit body
```
### 9.5 Cache bust
The Quasar PWA service worker is configured with `skipWaiting: true` +
`clientsClaim: true` + `cleanupOutdatedCaches: true`. A user who's been
sitting on a stale tab usually picks up the new build on next nav. For
a hard bust, append `?v=<epoch>` to the URL or instruct the user to
do a reload; the hash router handles the query string transparently.
---
## 10. Failure Modes
| Symptom | Likely cause | Remedy |
|-----------------------------------------------------|---------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|
| User clicks magic link, lands on `/login` | JWT expired (>24 h) | Re-request a link. Payment-return pages show a banner with a one-click re-auth button |
| User clicks magic link, lands on dashboard briefly, then bounces to `/login` on refresh | Store not persisted; token already stripped from URL | Expected — refresh is a fresh auth. Re-request or click from the original email again |
| "Lien envoyé" but no SMS arrives | Twilio rejected the number (no `cell_phone`/`tel_home` on file, or number invalid) | Check `docker logs targo-hub | grep 'Portal link'` — the log reports `sent via email` vs `NONE` |
| "Lien envoyé" but no email arrives | Mailjet rate limit / bounce | Same log line. Investigate Mailjet dashboard for the destination |
| Dashboard renders blank after click | `VITE_ERP_TOKEN` broken; ERPNext returns 401 | Browser console shows `[portal] ERPNext returned 401 — check VITE_ERP_TOKEN`. Rebuild with the right token |
| `client.gigafibre.ca` serves the old portal | Traefik dynamic config not reloaded / not placed in `/opt/traefik/dynamic/` | `scp` via `deploy-portal.sh`, then `docker logs --tail 50 traefik` |
| Deploy succeeds, but users still see old UI | Docker bind mount still pinned to backup inode | `ssh … docker restart client-portal` (deploy.sh does this; if you copied files manually, restart manually) |
| SSE never connects in `/messages` | CSP or mixed-content block on `msg.gigafibre.ca` | Check browser console — the nginx `Content-Security-Policy` must allow `connect-src https://msg.gigafibre.ca` |
| Hash router breaks behind corporate proxy | Proxy strips the `#` fragment | Issue a plain `/login` link (no token) — users self-request magic link from there |
| 429 loop for a well-meaning user | Same identifier typed 4 times inside 15 min | Wait the window out, or restart the hub container to flush the Map |
### 10.1 CSP for the hash router
The nginx container's config (`/opt/client-app/nginx.conf`, preserved
across deploys by the `cp docker-compose.yml nginx.conf` lines in
`deploy.sh`) must include at minimum:
```
connect-src 'self' https://msg.gigafibre.ca https://erp.gigafibre.ca;
```
The first permits hub SSE and REST; the second permits ERPNext REST via
`authFetch`. If either is missing, the SPA loads but every data call fails.
---
## 11. Retirement Notes
### 11.1 ERPNext `/login` — retired for customers
- The form is still reachable at `erp.gigafibre.ca/login` but is gated
behind Authentik forwardAuth — non-staff accounts cannot authenticate.
- `client.gigafibre.ca/login` (the old direct ERPNext frontend) no
longer serves the form because the whole host is 307-redirected to
`portal.gigafibre.ca/login`.
- No migration of legacy MD5 password hashes is required. Ever. See
memory `project_portal_auth.md` for rationale.
### 11.2 `client.gigafibre.ca` — legacy alias, not canonical
- All outbound communications (email templates, SMS, invoice footers,
QR codes on PDFs) now use `https://portal.gigafibre.ca/#/…`. See
`project_invoice_qr.md` memory for the QR wiring.
- The redirect in `traefik-client-portal.yml` is currently a `302`
(`permanent: false`) while the cutover is still warm — we can flip
to `301` once we're confident nothing else points at the old host.
Tracking this in [../roadmap.md](../roadmap.md).
- DNS: `*.gigafibre.ca` wildcard already resolves to `96.125.196.67`.
Let's Encrypt auto-provisions the cert for both hosts.
### 11.3 Authentik — not a factor for customers
Staff continue to use Authentik forwardAuth for Ops (`ops.gigafibre.ca`)
and ERPNext (`erp.gigafibre.ca`). The portal host has no Authentik
middleware; the JWT is the sole credential. Memory files
`reference_authentik.md` and `reference_authentik_client.md` cover
the staff side.
---
## 12. Glossary
- **Magic link** — `https://portal.gigafibre.ca/#/?token=<JWT>`. The only
authenticated entry point for customers. Minted by the hub, delivered
via SMS + email.
- **Customer JWT** — HS256 token with `scope=customer`, 24 h TTL,
`sub=<Customer.name>`. Decoded client-side; never re-verified.
- **Anti-enumeration** — The contract that every `POST /portal/request-link`
returns 200 regardless of whether the identifier matched.
- **Hash router** — Vue Router's `createWebHashHistory` mode. URL paths
live after `#`, so the server always serves the same `index.html`.
- **Inode trap** — The Docker bind-mount behaviour where `mv`-swapping a
directory leaves the container pointed at the old inode. Solved by
`docker restart client-portal`.
- **Legacy alias** — `client.gigafibre.ca`. Still resolves, still has a
cert, but 307-redirects every request to the canonical host.
- **Service token** — `VITE_ERP_TOKEN`: a fixed ERPNext API key baked
into the SPA bundle. Identifies the *portal* to ERPNext; not the
customer. Customer identity is enforced at the query layer.
---
*Last updated: 2026-04-22. Owners: Targo Platform team
(<louis@targo.ca>). Related roadmap items: hub JWT verification on
`/payments/*`, flip `client.gigafibre.ca` redirect to 301, Redis-backed
rate limit for multi-replica hub. See [../roadmap.md](../roadmap.md).*
---
Back to [docs/README.md](../README.md) · [roadmap.md](../roadmap.md)