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>
463 lines
20 KiB
Markdown
463 lines
20 KiB
Markdown
# Tech Mobile — Field Technician App
|
||
|
||
> **One feature, three delivery surfaces.** The tech-facing UI (today's jobs,
|
||
> scan-to-identify, equipment install/remove, diagnostic probes) has been
|
||
> rewritten twice and the codebase currently carries all three copies. Know
|
||
> which one a bug is actually in before you start editing.
|
||
|
||
**Last refreshed:** 2026-04-22
|
||
|
||
---
|
||
|
||
## 1. The three surfaces (read this first)
|
||
|
||
| Surface | URL in the SMS today | Hosted at | Status |
|
||
|---|---|---|---|
|
||
| **Legacy SSR** | `https://msg.gigafibre.ca/t/{jwt}` | `services/targo-hub/lib/tech-mobile.js` — single-file server-rendered HTML+inline JS | **Live.** This is what techs actually open today. |
|
||
| **Transitional PWA** | — (self-navigated) | `apps/field/` — standalone Quasar PWA at `https://erp.gigafibre.ca/field/` behind Authentik | **Live but retiring.** Used by techs who bookmark it; no SMS points here. |
|
||
| **Unified Ops tech module** | — (target) | `apps/ops/src/modules/tech/` mounted at `https://erp.gigafibre.ca/ops/#/j/` | **Live, on deck.** Same auth path as Ops; magic-link handler at `/j/:token` is wired but SMS generator still points at the SSR page. |
|
||
|
||
**The migration plan** is to flip `FIELD_APP_URL` in `services/targo-hub/lib/config.js`
|
||
from `https://msg.gigafibre.ca` to `https://erp.gigafibre.ca/ops/#/j` (or
|
||
update `generateLink()` to emit that URL directly), at which point the Vue
|
||
SPA becomes the primary surface and the SSR page becomes a fallback for
|
||
browsers too old to parse the SPA. Both the legacy SSR page and the Vue
|
||
pages talk to the same hub endpoints, so cutover is a one-line change plus a
|
||
regression pass.
|
||
|
||
```text
|
||
┌────────────────────────────────────────┐
|
||
│ Dispatch Job created / reassigned │
|
||
│ (Ops staff in /ops/#/dispatch) │
|
||
└──────────────────┬─────────────────────┘
|
||
│ POST /magic-link/refresh
|
||
│ (nightly cron + on-demand)
|
||
▼
|
||
┌───────────────────────────────────┐
|
||
│ targo-hub / magic-link.js │
|
||
│ mint JWT (HS256, 72h) │
|
||
│ + Twilio SMS to tech.phone │
|
||
└──────────────────┬────────────────┘
|
||
│ link:
|
||
│ https://msg.gigafibre.ca/t/{jwt}
|
||
▼
|
||
┌──────────────────────────────────────────┐
|
||
│ TECH TAPS LINK ON PHONE │
|
||
└──────────────────┬───────────────────────┘
|
||
│ (today) │ (target)
|
||
▼ ▼
|
||
┌──────────────────────────┐ ┌─────────────────────────────┐
|
||
│ hub/lib/tech-mobile.js │ │ apps/ops/src/modules/tech/ │
|
||
│ server-renders HTML, │ │ Vue SPA; magic-link route │
|
||
│ inline JS, <15KB │ │ /j/:token persists JWT in │
|
||
│ Same /vision/* calls │ │ localStorage then redirects │
|
||
└──────────────────────────┘ └─────────────────────────────┘
|
||
│ │
|
||
└──────┬──────────────┘
|
||
│ all surfaces call:
|
||
▼
|
||
POST https://msg.gigafibre.ca/vision/barcodes
|
||
POST https://msg.gigafibre.ca/vision/equipment
|
||
POST /api/resource/Dispatch Job/{name}
|
||
POST /api/resource/Service Equipment
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Auth: magic-link JWT (shared by all three surfaces)
|
||
|
||
**Implemented in:** `services/targo-hub/lib/magic-link.js`
|
||
|
||
Two token shapes:
|
||
|
||
```js
|
||
// Job-scoped: gives access to a single Dispatch Job
|
||
{ sub: techId, job: jobId, iat, exp }
|
||
|
||
// Tech-scoped: gives access to every job assigned to that tech
|
||
{ sub: techId, scope: 'all', iat, exp }
|
||
```
|
||
|
||
**TTL:** 72 hours (hard-coded in `generateLink()` / `generateTechLink()`).
|
||
The customer portal uses 24h; techs get longer so a Monday SMS covers a
|
||
full ticket lookahead.
|
||
|
||
**Signing:** HS256 using `JWT_SIGNING_KEY` from the hub env. Same secret
|
||
signs the customer-portal tokens — rotating it logs everyone out, so it's
|
||
only rotated on incident.
|
||
|
||
**SMS sender flow (`magic-link.js` → `twilio.js`):**
|
||
|
||
```text
|
||
POST /magic-link/refresh { tech_id }
|
||
↓ ERPNext: lookup Dispatch Technician → phone
|
||
↓ generate tech-level JWT (scope=all, 72h)
|
||
↓ Twilio: SMS "Voici votre nouveau lien pour accéder à vos tâches: <url>"
|
||
↓ log to targo-hub stdout (tech_id + phone number, no token)
|
||
200 { ok: true, sent_to: "+15145551234" }
|
||
```
|
||
|
||
**Token verification:**
|
||
|
||
```text
|
||
GET /magic-link/verify?token=<jwt>
|
||
200 { ok, tech_id, job_id?, scope, exp }
|
||
401 { error: 'invalid_or_expired',
|
||
message: 'Ce lien a expiré. Un nouveau lien vous sera envoyé par SMS.' }
|
||
```
|
||
|
||
**On the Vue SPA side** (`apps/ops/src/modules/tech/pages/TechTasksPage.vue`,
|
||
route `/j/:token`): the component reads `route.params.token`, calls
|
||
`/magic-link/verify`, stores `{tech_id, exp}` in localStorage, then
|
||
`router.replace({ name: 'tech-tasks' })` so the URL no longer contains the
|
||
raw JWT. Any subsequent 401 re-triggers the expired flow.
|
||
|
||
---
|
||
|
||
## 3. Scanner — native camera + Gemini (no JS library)
|
||
|
||
> **This is the whole point of the feature.** No `html5-qrcode`, no
|
||
> `html5core`, no `ZXing`, no `BarcodeDetector` polyfill. Just the browser's
|
||
> file picker in "camera" mode and a POST to the hub.
|
||
|
||
**Implementation:** `apps/ops/src/composables/useScanner.js` (Vue SPA) and
|
||
`apps/field/src/composables/useScanner.js` (transitional PWA). The two
|
||
files are byte-identical minus imports — kept in sync by the revert
|
||
bookmark at commit `e50ea88`. The SSR page
|
||
(`services/targo-hub/lib/tech-mobile.js`) inlines an equivalent ~40 lines.
|
||
|
||
### 3a. The markup
|
||
|
||
```html
|
||
<input
|
||
ref="cameraInput"
|
||
type="file"
|
||
accept="image/*"
|
||
capture="environment"
|
||
class="hidden"
|
||
@change="onPhoto" />
|
||
```
|
||
|
||
`capture="environment"` tells iOS Safari / Android Chrome to open the rear
|
||
camera directly with autofocus engaged. We do **not** stream `getUserMedia`
|
||
ourselves — a JS camera has worse focus on close-up serial labels than the
|
||
OS camera does, and burns battery. The user frames one photo, taps the
|
||
shutter, the `<input>`'s `change` event fires with a `File` object, and we
|
||
hand it off.
|
||
|
||
### 3b. The pipeline
|
||
|
||
```text
|
||
File (1–4 MB JPEG)
|
||
↓ createImageBitmap → 400px thumbnail (for instant UI preview)
|
||
↓ createImageBitmap → 1600px long-edge JPEG @ quality 0.92
|
||
↓ base64 encode (~500 KB over the wire)
|
||
↓ POST https://msg.gigafibre.ca/vision/barcodes
|
||
↓ body: { image: "data:image/jpeg;base64,…" }
|
||
↓ 200 { barcodes: ["1608K44D9E79FAFF5", "0418D6A1B2C3", "TPLG-A1…"] }
|
||
↓ onNewCode(code) callback fires per string, up to MAX_BARCODES = 5
|
||
```
|
||
|
||
**Timeout:** `SCAN_TIMEOUT_MS = 8000`. Beyond that we give up and either
|
||
(a) offer a retry, or (b) enqueue the image to the offline queue if
|
||
offline.
|
||
|
||
**Why 3 codes max:** the hub's `responseSchema` caps `barcodes` at 3 items
|
||
(see `services/targo-hub/lib/vision.js`). A single label often has 3–5
|
||
barcodes (EAN, S/N, MAC) stacked — we take the first 3 Gemini ranks as
|
||
most-confident and let the user tap the one that matches. The SPA keeps a
|
||
rolling window of 5 so a tech can re-scan without losing the prior match.
|
||
|
||
### 3c. Equipment-label mode (structured, not just strings)
|
||
|
||
When the user opens the scanner **from the "Scanner un code-barres" option
|
||
inside the equipment bottom-sheet** (not the top-level `/j/scan` page), we
|
||
hit `/vision/equipment` instead. That endpoint returns structured fields
|
||
ready to pre-fill the "Create equipment" dialog:
|
||
|
||
```json
|
||
{
|
||
"equipment": [
|
||
{
|
||
"serial": "1608K44D9E79FAFF5",
|
||
"mac": "04:18:D6:A1:B2:C3",
|
||
"brand": "Nokia",
|
||
"model": "G-140W-C",
|
||
"type": "ONT"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
`maxItems: 5`. Same 1600px JPEG. Same 8s timeout. Same offline queue.
|
||
|
||
### 3d. Offline queue
|
||
|
||
**Implemented in:** `apps/ops/src/stores/offline.js` (Pinia) via
|
||
`idb-keyval` at key `tech-vision-queue`.
|
||
|
||
```js
|
||
// When fetch() rejects or navigator.onLine === false:
|
||
enqueueVisionScan({ id, endpoint, imageDataUri, ts, consumer })
|
||
→ persist to IndexedDB
|
||
|
||
// On navigator.online event:
|
||
watch(isOnline, async now => {
|
||
if (now) flushQueue() // replays each scan, fires original consumer
|
||
})
|
||
```
|
||
|
||
The consumer identifier (`'tech-scan' | 'tech-equip-scan' | …`) lets the
|
||
UI route the late result to the right dialog once the user is back online.
|
||
Scans older than 7 days are dropped on boot.
|
||
|
||
---
|
||
|
||
## 4. The Vue SPA surface (`apps/ops/src/modules/tech/`)
|
||
|
||
Routes, defined in `apps/ops/src/router/index.js`:
|
||
|
||
```js
|
||
path: '/j',
|
||
component: TechLayout,
|
||
children: [
|
||
{ path: '', name: 'tech-tasks', component: TechTasksPage },
|
||
{ path: 'job/:name', name: 'tech-job', component: TechJobDetailPage, props: true },
|
||
{ path: 'scan', name: 'tech-scan', component: TechScanPage },
|
||
{ path: 'device/:serial', name: 'tech-device', component: TechDevicePage, props: true },
|
||
{ path: 'diagnostic', name: 'tech-diag', component: TechDiagnosticPage },
|
||
{ path: 'more', name: 'tech-more', component: TechMorePage },
|
||
{ path: ':token', name: 'tech-magic', component: TechTasksPage, props: true }, // must be LAST
|
||
],
|
||
```
|
||
|
||
The magic-link route is deliberately the last child — Vue Router's pattern
|
||
matcher would otherwise swallow `/j/scan` as `token=scan`. If you add a
|
||
new static child, put it **above** the `:token` entry.
|
||
|
||
### 4a. `TechTasksPage.vue` — today's schedule
|
||
|
||
Fetches `Dispatch Job` rows filtered by `assigned_tech == sub` and
|
||
`scheduled_date == today` (UTC-Toronto). Groups them by status
|
||
("À venir", "En cours", "Terminée"). Each row navigates to
|
||
`/j/job/:name`.
|
||
|
||
### 4b. `TechJobDetailPage.vue` — the main tech surface
|
||
|
||
**458 lines. This is where the ported equipment UX lives — see §5.**
|
||
|
||
Three cards:
|
||
|
||
| Card | Fields | Writes? |
|
||
|---|---|---|
|
||
| **Info** | Type (Installation / Dépannage / …), Priority, Duration, Status chip, Description textarea | Yes — `saveField()` debounced 500ms PUTs to `Dispatch Job` |
|
||
| **Location** | Address (click → Google Maps GPS), contact name, contact phone (expandable, lazy-loads `Service Location`) | No — read-only |
|
||
| **Equipment** | List of `Service Equipment` linked to the job; "Ajouter un équipement" bottom-sheet | Yes — see §5 |
|
||
|
||
Bottom-of-page action buttons:
|
||
- Status `Scheduled` → "Démarrer" → PUT status `In Progress`
|
||
- Status `In Progress` → "Terminer" → PUT status `Completed`
|
||
- Status `Completed` → no button (dispatch has to reopen)
|
||
|
||
Both status values have legacy aliases (`assigned`, `in_progress`) because
|
||
the SSR page uses ERPNext's Title Case and older Python scripts emit
|
||
lowercase. The computed `statusLabel` handles both.
|
||
|
||
### 4c. `TechScanPage.vue` — standalone scanner
|
||
|
||
The top-level scanner route. Accepts any barcode, looks up by serial in
|
||
ERPNext, routes to `/j/device/:serial` on a hit or offers "Create
|
||
equipment" on a miss. Useful for techs auditing a van inventory or
|
||
confirming a serial before scheduled install time.
|
||
|
||
### 4d. `TechDevicePage.vue` — per-device detail
|
||
|
||
GET `Service Equipment/:serial` → shows brand, model, MAC, install date,
|
||
linked customer, linked address, recent diagnostic runs (via
|
||
`useSpeedTest` composable). "Retirer" button unlinks from current job
|
||
(PUT `linked_job: null`) but preserves the doc for audit.
|
||
|
||
### 4e. `TechDiagnosticPage.vue` — speed test + GenieACS probe
|
||
|
||
Runs an in-browser speed test via `useSpeedTest` and, if the device is
|
||
ACS-managed, fires a `GetParameterValues` against GenieACS through
|
||
`targo-hub /acs/probe`. Results render as a one-page PDF the tech can
|
||
SMS to the customer.
|
||
|
||
### 4f. `TechMorePage.vue` — settings, logout, version
|
||
|
||
Shows tech name, token expiry countdown, link to "Request new magic
|
||
link" (POSTs `/magic-link/refresh`), and a hard logout that wipes
|
||
localStorage.
|
||
|
||
---
|
||
|
||
## 5. Equipment management (the UX that was re-ported 2026-04-22)
|
||
|
||
This section drove today's port from `apps/field/src/pages/JobDetailPage.vue`
|
||
→ `apps/ops/src/modules/tech/pages/TechJobDetailPage.vue`. The Ops SPA
|
||
stub had dropped it. It's back.
|
||
|
||
### 5a. The bottom-sheet trigger
|
||
|
||
```vue
|
||
<q-btn flat dense label="Ajouter" icon="add" @click="addEquipmentMenu = true" />
|
||
|
||
<q-dialog v-model="addEquipmentMenu" position="bottom">
|
||
<q-list>
|
||
<q-item clickable v-close-popup @click="goToScanner">
|
||
<q-item-section avatar><q-icon name="qr_code_scanner" /></q-item-section>
|
||
<q-item-section>Scanner un code-barres / QR</q-item-section>
|
||
</q-item>
|
||
<q-item clickable v-close-popup @click="searchEquipDialog = true">
|
||
<q-item-section avatar><q-icon name="search" /></q-item-section>
|
||
<q-item-section>Rechercher un équipement existant</q-item-section>
|
||
</q-item>
|
||
<q-item clickable v-close-popup @click="createEquipDialog = true">
|
||
<q-item-section avatar><q-icon name="add_circle" /></q-item-section>
|
||
<q-item-section>Créer un nouvel équipement</q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
</q-dialog>
|
||
```
|
||
|
||
### 5b. Scan → link
|
||
|
||
`goToScanner()` `router.push({ name: 'tech-scan' })`, passing the current
|
||
`jobName` in query string. `TechScanPage` on a barcode hit calls
|
||
`linkEquipToJob(equipment)` which PUTs `linked_job: jobName` on the
|
||
`Service Equipment` row.
|
||
|
||
### 5c. Search → link
|
||
|
||
Debounced 400ms on `eqSearchText`. First query is by `serial_number`;
|
||
if that returns empty and the string looks like a MAC
|
||
(hex+separators), falls through to `mac_address`. Results render as
|
||
selectable rows; tap → `linkEquipToJob()`.
|
||
|
||
### 5d. Create → link (pre-filled from scan or blank)
|
||
|
||
```js
|
||
const newEquip = ref({
|
||
serial_number: '', // autofocus
|
||
equipment_type: 'ONT', // select
|
||
brand: '', // Marque
|
||
model: '', // Modèle
|
||
mac_address: '', // MAC optionnel
|
||
})
|
||
|
||
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Décodeur TV',
|
||
'VoIP', 'Amplificateur', 'Splitter', 'Câble', 'Autre']
|
||
```
|
||
|
||
`createAndLinkEquip()` POSTs a `Service Equipment` doc with these fields
|
||
plus `status: 'In Service'`, `linked_job: jobName`,
|
||
`service_location: job.service_location`. The doc name is auto-generated
|
||
(ERPNext auto-series `EQ-.####`).
|
||
|
||
### 5e. "Retirer" (unlink from job, preserve doc)
|
||
|
||
Button on each equipment row. PUT `linked_job: null` + optional
|
||
`status: 'Returned'`. The doc itself is never deleted — audit trail
|
||
matters.
|
||
|
||
---
|
||
|
||
## 6. Hub API endpoints (all three surfaces call these)
|
||
|
||
Direct ERPNext calls via the tech's JWT (the Vue SPA wraps these in
|
||
`src/api/erp.js`):
|
||
|
||
```
|
||
GET /api/resource/Dispatch%20Job?filters=… → list today's jobs
|
||
GET /api/resource/Dispatch%20Job/:name → job detail
|
||
PUT /api/resource/Dispatch%20Job/:name → status, priority, etc.
|
||
GET /api/resource/Service%20Equipment?filters=… → list linked
|
||
GET /api/resource/Service%20Equipment/:serial → detail
|
||
POST /api/resource/Service%20Equipment → create
|
||
PUT /api/resource/Service%20Equipment/:serial → link/unlink
|
||
GET /api/resource/Service%20Location/:name → expand location
|
||
```
|
||
|
||
Hub-mediated calls (all `https://msg.gigafibre.ca/…`):
|
||
|
||
```
|
||
POST /vision/barcodes → Gemini vision, {image} → {barcodes: []}
|
||
POST /vision/equipment → Gemini vision, {image} → {equipment: []}
|
||
POST /magic-link/refresh → reissue + SMS → {sent_to: "+1…"}
|
||
GET /magic-link/verify?token → check expiry → {tech_id, exp}
|
||
POST /acs/probe → GenieACS GetParamValues → {params: {}}
|
||
```
|
||
|
||
The legacy SSR surface at `/t/{token}/…` replicates a subset of these
|
||
(`/scan`, `/vision`, `/equip`, `/equip-remove`, `/status`, `/catalog`,
|
||
`/equip-list`) but as token-scoped paths rather than REST + JWT header.
|
||
When the SSR page is retired those paths will 410 Gone.
|
||
|
||
---
|
||
|
||
## 7. File inventory
|
||
|
||
```text
|
||
apps/ops/src/modules/tech/pages/
|
||
├── TechTasksPage.vue (today's jobs, grouped by status)
|
||
├── TechJobDetailPage.vue ← ported 2026-04-22 (equipment UX)
|
||
├── TechScanPage.vue (standalone scanner)
|
||
├── TechDevicePage.vue (per-device detail)
|
||
├── TechDiagnosticPage.vue (speed test + ACS probe)
|
||
└── TechMorePage.vue (settings, logout, token expiry)
|
||
|
||
apps/ops/src/layouts/
|
||
└── TechLayout.vue (bottom tab bar, mobile-optimized viewport)
|
||
|
||
apps/ops/src/composables/
|
||
└── useScanner.js (native <input> + Gemini, offline queue)
|
||
|
||
apps/ops/src/stores/
|
||
└── offline.js (IndexedDB queue, online-event flush)
|
||
|
||
apps/ops/src/api/
|
||
└── erp.js (getDoc, listDocs, createDoc, updateDoc wrappers)
|
||
|
||
apps/field/ ← transitional PWA, same layout, retiring
|
||
└── src/pages/JobDetailPage.vue (the 526-line reference for the port)
|
||
|
||
services/targo-hub/lib/
|
||
├── tech-mobile.js ← legacy SSR surface at /t/{token}
|
||
├── magic-link.js ← JWT mint/verify/refresh, shared w/ portal
|
||
└── vision.js ← /vision/barcodes + /vision/equipment
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Open questions / known gaps
|
||
|
||
- [ ] **Cutover**: flip `FIELD_APP_URL` (or `generateLink()`) to point at
|
||
`https://erp.gigafibre.ca/ops/#/j` and retire the SSR page. Needs a
|
||
regression sweep of the equipment bottom-sheet on iOS Safari 16 and
|
||
Android Chrome 120.
|
||
- [ ] **Offline create-equipment**: `createDoc` is not queued yet. If a
|
||
tech creates a device while offline the operation silently
|
||
`catch()`es. Needs a write queue analogous to `enqueueVisionScan`.
|
||
- [ ] **Dual-language strings**: UI is French-only. Roadmap item to
|
||
extract to `src/i18n/` once Ops desktop i18n lands.
|
||
- [ ] **Equipment type normalization**: `eqTypes` array is hard-coded.
|
||
ERPNext has an "Equipment Type" doctype — wire the dropdown to it
|
||
so new types don't require a frontend deploy.
|
||
- [ ] **GenieACS auto-probe**: currently the tech has to navigate to
|
||
`/j/diagnostic` manually. Could fire a background probe the moment
|
||
a device is scanned.
|
||
|
||
---
|
||
|
||
## 9. Related docs
|
||
|
||
- [features/vision-ocr.md](vision-ocr.md) — the Gemini pipeline backing the scanner
|
||
- [features/dispatch.md](dispatch.md) — where `Dispatch Job` rows are created + assigned
|
||
- [features/customer-portal.md](customer-portal.md) — same magic-link pattern, 24h TTL, different token schema
|
||
- [features/cpe-management.md](cpe-management.md) — GenieACS probe called by `TechDiagnosticPage`
|
||
- [architecture/overview.md](../architecture/overview.md) — Traefik routes, Authentik wrapping, hub placement
|
||
- [architecture/module-interactions.md](../architecture/module-interactions.md) — cross-module read/write matrix
|
||
|
||
Back to [docs/features/README.md](README.md) · [docs/README.md](../README.md)
|