Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
845db14e17 | ||
|
|
337f8716bb | ||
|
|
257a4cf6c0 | ||
|
|
4a268c7351 | ||
|
|
294a8176d1 | ||
|
|
a5024f7190 | ||
|
|
38f654b97a | ||
|
|
f04a81f6c7 | ||
|
|
07d5605812 | ||
|
|
65835ea40a | ||
|
|
5fa4fe0811 | ||
|
|
cf9a889547 | ||
|
|
ddc3a98e4b | ||
|
|
6b864596e0 | ||
|
|
14c2ad3ee8 | ||
|
|
9fff84dc68 | ||
|
|
c63903cc94 | ||
|
|
ac427f4427 | ||
|
|
728e380d63 | ||
|
|
8989ffb23c | ||
|
|
7c94eb9e87 | ||
|
|
e0c7ed6011 | ||
|
|
5b2827eb7e | ||
|
|
8a3a43d4ac | ||
|
|
ff118dd66d | ||
|
|
824048b9d4 | ||
|
|
c981aad2a3 | ||
|
|
e115031a57 | ||
|
|
ec8f809497 | ||
|
|
4a0a4d1cb4 | ||
|
|
1867828a9d | ||
|
|
4437b8792a | ||
|
|
4bc3e33953 | ||
|
|
477b5e7ce9 | ||
|
|
6e227a214a | ||
|
|
9bd6bea759 | ||
|
|
7e34f55005 | ||
|
|
f5212959b4 | ||
|
|
62ac9f825c | ||
|
|
bdc58f396e | ||
|
|
e1f0ad8431 | ||
|
|
760f3b6ee2 | ||
|
|
ffc88e33b4 | ||
|
|
56a65b2364 | ||
|
|
063a8f9a2d | ||
|
|
938a739dfe |
@@ -8,8 +8,18 @@ POSTGRES_DB=mobilityops
|
||||
POSTGRES_USER=mobilityops
|
||||
POSTGRES_PASSWORD=mobilityops
|
||||
APP_SECRET=replace-in-production
|
||||
DEMO_TODAY=2026-08-01
|
||||
TZ=Europe/Brussels
|
||||
# Session cookie Secure flag. Keep false for LAN/plain-HTTP deployments (including the
|
||||
# current Unraid review environment); set true only once MobilityOps is served over HTTPS,
|
||||
# otherwise browsers will silently drop the cookie and no one can log in.
|
||||
SESSION_COOKIE_SECURE=false
|
||||
|
||||
# Demo presentation (fictional org identity, badge/manifest, reset safety valve).
|
||||
# DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent
|
||||
# of role -- a safety valve for any environment where the dataset must not be rebuildable.
|
||||
DEMO_ORGANIZATION_NAME=Northstar Mobility
|
||||
DEMO_TIMEZONE=Europe/Brussels
|
||||
DEMO_ALLOW_RESET=true
|
||||
|
||||
# n8n
|
||||
N8N_BASE_URL=http://n8n:5678
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: up down logs test lint seed reset n8n-setup demo e2e
|
||||
.PHONY: up down logs test lint seed reset n8n-setup n8n-setup-scan demo e2e
|
||||
|
||||
up:
|
||||
docker compose up --build -d
|
||||
@@ -32,6 +32,13 @@ n8n-setup:
|
||||
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
|
||||
docker compose restart n8n
|
||||
|
||||
# One-time per environment: imports and activates the scheduled quality-scan workflow.
|
||||
# Same owner-account precondition as n8n-setup above.
|
||||
n8n-setup-scan:
|
||||
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-scheduled-quality-scan.json
|
||||
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
|
||||
docker compose restart n8n
|
||||
|
||||
# Full deterministic demo bootstrap: build, migrate (automatic on api startup), seed.
|
||||
demo: up
|
||||
docker compose exec api python -m app.cli seed --reset
|
||||
|
||||
@@ -320,3 +320,501 @@ None. External service credentials may be absent; use the documented demo/degrad
|
||||
- Exact next action: hand off `design/mobilityops-premium-ui` for review. The final code,
|
||||
shared-n8n topology and evidence are committed, pushed and deployed; do not merge master
|
||||
automatically.
|
||||
|
||||
## Functional completion pass (branch `feat/mobilityops-functional-completion`)
|
||||
|
||||
Branched from `design/mobilityops-premium-ui` @ `54dc952`. Full audit at
|
||||
`docs/functional-completion/current-functional-audit.md`; server baseline captured before
|
||||
any change at `docs/functional-completion/server-baseline.md`.
|
||||
|
||||
### Batch 1 — complete (commits `938a739`..`bdc58f3`)
|
||||
|
||||
- Fixed the two confirmed list-rendering defects: Vehicles and Bookings both computed a
|
||||
filtered/paginated result but rendered the raw unfiltered array in the table body.
|
||||
- Added server-backed session lifecycle: `GET /api/v1/demo/session` (Cache-Control:
|
||||
no-store — a cached 200 was making logout intermittently fail to redirect in e2e
|
||||
testing), `POST /api/v1/demo/logout`. `AuthContext` now verifies against the server on
|
||||
every mount instead of trusting `sessionStorage`, and a central 401 listener on the API
|
||||
client clears auth state from any endpoint.
|
||||
- Enforced the brief's role matrix: data-quality (list/detail/defer/reject) and the audit
|
||||
trail were reachable by Rental Employee with no gate beyond authentication (confirmed
|
||||
live via curl before the fix). Both are now `require_operations_manager`-gated
|
||||
server-side, with matching nav-hiding and a restricted-message fallback for direct URL
|
||||
access, and the dashboard no longer links into those areas for that role.
|
||||
- Discovered and fixed a latent e2e-suite bug while testing against the real server: all
|
||||
three spec files hardcoded `http://localhost:8128` for their demo-reset helpers, so
|
||||
pointing the suite at Unraid via `MOBILITYOPS_PUBLIC_URL` silently kept resetting the
|
||||
*local* dev database instead. Switched to relative paths so the configured `baseURL` is
|
||||
honoured.
|
||||
- Local evidence: `pytest` 75 passed, `ruff check .` clean, `mypy app` 0 issues/44 files,
|
||||
`npx tsc -b` clean, `npm run build` clean, `npx playwright test` **25 passed** (up from
|
||||
19 — 6 new tests this batch), stable across three repeated full-suite runs.
|
||||
- Deployed to Unraid (`.deploy/source-revision` = `bdc58f396e99caaf6ef657bb110b479981cc7793`,
|
||||
matches `git rev-parse HEAD` on the feature branch), migrations unchanged at
|
||||
`e7b08389f47f (head)` (no schema change this batch), demo reset run. Re-verified live:
|
||||
role-gate curl checks (403/200/401 as expected) and the full 25-test Playwright suite
|
||||
run with `MOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236` — **25 passed** against the
|
||||
actual deployment, not just localhost.
|
||||
- Exact next action: Batch 2 — authoritative return-preview endpoint shared with commit,
|
||||
and expose `before`/`after` on the audit API + UI.
|
||||
|
||||
### Batch 2 — complete (commits `f521295`, `7e34f55`)
|
||||
|
||||
- Added `evaluate_return()` (pure, no writes) in `returns.py`, extracted from what
|
||||
`register_vehicle_return` already computed inline; `register_vehicle_return` now calls
|
||||
it instead of duplicating the logic. New non-mutating `POST
|
||||
/api/v1/bookings/{ref}/return-preview` uses the same function, so preview and commit
|
||||
cannot drift.
|
||||
- Fixed a real defect this surfaced: `ReturnForm.tsx`'s review step guessed the outcome
|
||||
client-side and got the domain rule wrong — it said damage/technical-warning routes to
|
||||
`maintenance` (actual rule: `blocked`) and the no-contradiction case becomes
|
||||
`available` (actual rule: always `cleaning` first, `maintenance` only past the service
|
||||
threshold). The review step now calls `/return-preview` and renders the server's
|
||||
`resulting_vehicle_status` + `status_reason` verbatim.
|
||||
- Result screen now distinguishes local commit success from n8n delivery ("queued... not
|
||||
yet confirmed" instead of implying both succeeded) and links to any created
|
||||
data-quality issue for Operations Manager.
|
||||
- Exposed `before`/`after` on `AuditEventOut` (the DB columns already existed but were
|
||||
never serialized) plus a resolved `entity_ref`/`entity_link` for vehicle/booking/
|
||||
data-quality-issue entities. `Audit.tsx` now shows a human-readable change summary per
|
||||
row with raw JSON behind a `<details>` disclosure instead of always-visible JSON.
|
||||
- New regression coverage: backend — preview performs no writes (asserted via audit/
|
||||
outbox row counts before vs. after), detects odometer regression, detects service-due,
|
||||
detects next-booking risk, requires an active booking, and matches the commit result;
|
||||
audit — before/after and entity link exposed for both `return_registered` and
|
||||
`vehicle_status_changed`. Frontend — preview correctly reports `blocked` (not
|
||||
`maintenance`) for damage, commit request only fires after confirm (updated to also
|
||||
assert exactly one preview call), audit page shows before/after and a safe link.
|
||||
- Local evidence: `pytest` 81 passed, `ruff check .` clean, `mypy app` 0 issues/44 files,
|
||||
`npx tsc -b` clean, `npm run build` clean, `npx playwright test` **27 passed**, stable
|
||||
across two repeated full-suite runs.
|
||||
- Deployed to Unraid and re-verified; demo data reset afterward.
|
||||
- Exact next action: Batch 3 — data-quality workbench (typed snapshots, bounded
|
||||
resolution flows for all 5 rule types, manual scan UI).
|
||||
|
||||
### Batch 3 — complete (commits `6e227a2`, `477b5e7`)
|
||||
|
||||
- Typed related-entity snapshots by the reference's own prefix (CUS-/MO-/BK-/INSP-)
|
||||
instead of inferring from `rule_type`. Fixed a real gap this exposed: a
|
||||
`booking_overlap` issue's related refs are bookings, but `get_issue` always resolved
|
||||
them as vehicles, so `_snapshot()` silently returned nothing for them.
|
||||
- Added one bounded resolution endpoint per remaining rule type: `provide-fields`
|
||||
(missing_required_field; re-runs the check, resolves only once nothing required is
|
||||
missing), `resolve-odometer-regression` (retain canonical or correct the reading —
|
||||
correction is rejected if it would still be below canonical), `resolve-overlap`
|
||||
(blocks one of the two bookings, re-verifies no overlap remains — found and fixed an
|
||||
autoflush=False bug where the re-verification query didn't see the just-blocked
|
||||
booking's in-memory status change), `apply-recommended-status` (one authoritative
|
||||
recommendation function mirroring the scan's own conflict conditions, re-validated
|
||||
after applying). `possible_duplicate_customer` already had merge; all five rule types
|
||||
now have a real bounded resolution path, not just generic defer/reject.
|
||||
- Reintroduced evidence after a non-open decision links the new issue back to the prior
|
||||
one (`evidence.reopened_from` / `previous_decision`) per the documented lifecycle
|
||||
("reintroduced evidence creates a new issue linked to the prior issue").
|
||||
- `DataQualityIssueDetail.tsx` rewritten: a typed panel per rule type instead of a raw
|
||||
`JSON.stringify` dump for four of five types; raw evidence moved behind a `<details>`
|
||||
disclosure. Added a "Run quality scan" action to the workbench (confirmation,
|
||||
progress, per-rule result counts, auto-refresh) — the scan endpoint already existed
|
||||
with no UI trigger.
|
||||
- New regression coverage: backend — one resolution test per rule type plus the
|
||||
role-gate/validation-rejection paths and the recurrence-linking behavior (reject an
|
||||
issue, rescan, assert the new issue links back). Frontend — one Playwright test per
|
||||
resolution flow plus the manual scan trigger.
|
||||
- Local evidence: `pytest` 96 passed, `ruff check .` clean, `mypy app` 0 issues/44 files,
|
||||
`npx tsc -b` clean, `npm run build` clean, `npx playwright test` **32 passed**, stable
|
||||
across two repeated full-suite runs.
|
||||
- Deployed to Unraid and re-verified against the live server; demo data reset afterward.
|
||||
- Exact next action: Batch 4 — global search backend + UI, demo reset UI trigger,
|
||||
truthful aggregate integration status (n8n/RAGcore/MCP).
|
||||
|
||||
### Batch 4 — complete (commits `4437b87`, `1867828`)
|
||||
|
||||
- Added `GET /api/v1/search` — bounded typed results (vehicle/booking/data-quality-issue/
|
||||
section), role-filtered server-side (data-quality and manager-only sections excluded
|
||||
for Rental Employee), customers never returned (no customer detail route exists).
|
||||
Replaced `Layout.tsx`'s blind client-side regex/term guesser with a debounced
|
||||
(250 ms) call to this endpoint, a real `role="listbox"` results panel, arrow-key
|
||||
navigation, Enter/Escape, outside-click close, and a no-results state.
|
||||
- Added `GET /api/v1/integrations/status`, aggregating outbox delivery counts into one
|
||||
truthful n8n state (`disabled`/`unavailable`/`degraded`/`operational`/`no_evidence`)
|
||||
instead of the dashboard/automation cards showing whichever status the single most
|
||||
recent event happened to be in. Wired into both `Automation.tsx` and `Dashboard.tsx`.
|
||||
MCP Hub card now reflects the real `registration_enabled` setting.
|
||||
- Found and fixed a real config gap this surfaced: `MCP_HUB_REGISTRATION_ENABLED` was
|
||||
documented in `.env.example` but had no `Settings` field, so it was silently dropped
|
||||
by `extra="ignore"` and never read anywhere in the codebase.
|
||||
- Added a "Reset demo data" action to the sidebar (Operations Manager only, confirm,
|
||||
progress, error handling) — the endpoint already existed and was already gated, just
|
||||
had no UI trigger. Reset invalidates the acting session server-side, so the flow signs
|
||||
the user out and returns to login.
|
||||
- New regression coverage: backend — search role-filtering/customer-exclusion/no-match,
|
||||
integration-status role-gate and state-derivation (including a test that resolves all
|
||||
seeded failures and asserts the state flips to `operational`). Frontend — vehicle/
|
||||
booking/data-quality-issue search navigation, keyboard nav, no-results + Escape, demo
|
||||
reset happy path, rental employee cannot see the reset button, automation page shows
|
||||
aggregate counts.
|
||||
- Local evidence: `pytest` 109 passed, `ruff check .` clean, `mypy app` 0 issues/46 files,
|
||||
`npx tsc -b` clean, `npm run build` clean, `npx playwright test` **37 passed**, stable
|
||||
across two repeated full-suite runs.
|
||||
- Deployed to Unraid and re-verified against the live server; demo data reset afterward.
|
||||
- Exact next action: Batch 5 — bounded outbox delivery-lease recovery for stale
|
||||
`delivering` events, a second (scheduled quality-scan) n8n workflow, final
|
||||
documentation/contract updates and acceptance evidence.
|
||||
|
||||
### Batch 5 — complete (commits `ec8f809`, `e115031`, `c981aad`, `824048b`)
|
||||
|
||||
- Fixed a real gap: `_claim_due_events` flipped rows to `delivering` and committed
|
||||
before the HTTP call, with no reclaim path if the process died before the outcome was
|
||||
recorded. Each claim now gets a lease deadline (`n8n_delivery_lease_seconds`, default
|
||||
120s, reusing the `next_attempt_at` column) and `run_dispatch_cycle()` sweeps expired
|
||||
leases back to `pending` before claiming new work; `attempts` is preserved, and a
|
||||
still-alive worker's unexpired lease is never touched.
|
||||
- Added the second n8n workflow: `POST /api/v1/integrations/n8n/scheduled-scan`
|
||||
(service-token protected, same pattern as the return callback) running the same
|
||||
`run_scan()` the manual UI action uses, audited with `actor_type=service`.
|
||||
`n8n/mobilityops-scheduled-quality-scan.json` (hourly + manual-test trigger) ships
|
||||
`"active": false`. Live-verified twice: executed end-to-end via the Manual test
|
||||
trigger against the **local** n8n instance (full green execution, confirmed via the
|
||||
resulting `data_quality_scan_run` audit event), and published + directly
|
||||
curl-round-tripped against the **shared Unraid n8n** and its live API
|
||||
(`deploy/unraid/setup-scheduled-scan.sh`). The shared instance's own UI could not be
|
||||
browser-tested directly — it runs `N8N_SECURE_COOKIE=true` and refuses login over the
|
||||
plain-HTTP LAN URL, which is correct/expected shared-infrastructure behaviour, not
|
||||
something this task should change.
|
||||
- Updated `contracts/openapi.yaml` and `docs/05-api-contract.md` with every endpoint
|
||||
added across all five batches; `docs/07-data-quality.md`, `docs/08-return-workflow.md`
|
||||
and `docs/12-security-and-audit.md` now describe the actual resolution flows, the
|
||||
preview/commit relationship, the role matrix and the audit before/after exposure.
|
||||
Corrected `docs/07-data-quality.md`'s lifecycle description to match the
|
||||
already-implemented `(rule_type, entity_type, entity_id)` idempotency key (no evidence
|
||||
fingerprint) and documented the `reopened_from`/`previous_decision` recurrence link.
|
||||
`README.md`'s scope/integration-status/quality-gate sections updated to match.
|
||||
- Local evidence: `pytest` **117 passed**, `ruff check .` clean, `mypy app` 0 issues/46
|
||||
files, `npx tsc -b` clean, `npm run build` clean, `npx playwright test` **37 passed**.
|
||||
- **Clean-checkout drill** (section 14): fresh `git clone` of this branch into an
|
||||
isolated scratch directory, `.env` from `.env.example`, isolated Compose project name
|
||||
and remapped host ports (no shared state with the working stack), `up --build -d`
|
||||
from empty volumes → migrations ran automatically → seed → full backend gate (117
|
||||
passed, ruff clean, mypy clean) → `npm ci` (clean; the pre-existing esbuild-moderate/
|
||||
react-router-RSC-high advisories are unchanged, not new) → `tsc -b`/`vite build` clean
|
||||
→ full Playwright suite **37 passed** against the isolated stack. Torn down afterward
|
||||
(`down -v` on the isolated project only; the working dev stack was never touched).
|
||||
- Deployed to Unraid; migrations unchanged at `e7b08389f47f (head)`. Full 37-test
|
||||
Playwright suite re-run against `http://192.168.10.150:1236` — **37 passed**. Demo
|
||||
data reset afterward.
|
||||
- Exact next action: none — all five batches are implemented, tested locally (including
|
||||
a genuine clean-checkout drill), committed, pushed, deployed to Unraid and
|
||||
re-verified against the live server after every batch. See
|
||||
`artifacts/functional-completion/final-summary.md` for the definitive acceptance
|
||||
evidence.
|
||||
|
||||
## Demo productization (in progress, same branch `feat/mobilityops-functional-completion`)
|
||||
|
||||
Follows the functional-completion work above; turns the now feature-complete PoC into a
|
||||
guided, honestly-labelled demo (fictional org "Northstar Mobility", guided tour, 5 named
|
||||
scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-demo-gap-audit.md`.
|
||||
|
||||
### Batch 1 — seed date anchoring (complete)
|
||||
|
||||
- **Real bug fixed**: `seed/bookings.csv` etc. store absolute ISO timestamps authored
|
||||
around a fixed anchor (`2026-08-01`). Nothing previously re-anchored them at seed/reset
|
||||
time, so scenario bookings (e.g. `BK-DEMO-RETURN`) silently drifted into the past every
|
||||
day the environment wasn't reset. `dashboard.py::_today()` compounded this by filtering
|
||||
"today's movements" against the same frozen `demo_today` setting instead of real time.
|
||||
- Fix: `seed_loader.py` now computes `shift = today - SEED_AUTHORED_ANCHOR` once per
|
||||
`load_seed()` call and applies it to every seeded booking/inspection/maintenance/outbox
|
||||
datetime column, so scenarios stay "today"/"near-future" relative to the actual reset
|
||||
moment. `SeedResult` now also carries `anchor_date`/`seeded_at`; `POST /api/v1/demo/reset`
|
||||
returns them; a `demo_data_seeded` audit event records the anchor for traceability.
|
||||
`dashboard.py::_today()` switched from the frozen `demo_today` setting to real wall-clock
|
||||
UTC date. The now-dead `demo_today` setting/env var was removed from `config.py`,
|
||||
`compose.yaml`, `.env`, `.env.example` (nothing else referenced it).
|
||||
- Added seed-validation tests (`backend/tests/test_seed.py`) proving S1 (`BK-DEMO-RETURN`/
|
||||
`MO-024`), S2 (`CUS-0012`/`CUS-0178`/`DQ-DEMO-DUPLICATE`), S4 (`MO-016`/
|
||||
`BK-DEMO-OVERLAP-A`/`-B`/`DQ-DEMO-OVERLAP`) and S5 (seeded failed outbox event
|
||||
`00000000-0000-4000-8000-000000000020`, confirmed genuinely `failed` immediately after a
|
||||
fresh reset, not silently auto-healed by the background dispatcher since it only claims
|
||||
`pending` rows) are fully present after every reset, plus a dedicated anchoring test
|
||||
asserting the shift and the audit marker.
|
||||
- Live-verified locally: reseeded and confirmed via `psql` that `BK-DEMO-RETURN` now ends
|
||||
today and `BK-DEMO-NEXT`/overlap bookings sit in the near future (today = 2026-08-03).
|
||||
- Evidence: `pytest` **122 passed** (117 + 5 new/expanded seed tests), `ruff check .`
|
||||
clean, `mypy app` clean (46 files, canonical `make` scope).
|
||||
- Deployed to Unraid (commit `8989ffb`): pushed to Gitea, `git archive` tarball extracted
|
||||
over `/mnt/user/appdata/mobilityops` preserving `.env`/volumes, `api` rebuilt (`db`/`web`
|
||||
untouched — no frontend changes this batch), migrations confirmed at `e7b08389f47f
|
||||
(head)`, reseeded, live-verified via `psql` that `BK-DEMO-RETURN`/`BK-DEMO-NEXT`/overlap
|
||||
bookings sit at the same real-time-relative positions as local. `curl` to
|
||||
`http://192.168.10.150:1236/` returns 200.
|
||||
- Exact next action: `GET /api/v1/demo/manifest` + Dutch demo entry screen + permanent
|
||||
demo badge (task #30), then the Demo Guide + scenario overview (task #31).
|
||||
|
||||
### Batch 2 — demo manifest, Dutch demo entry, permanent demo badge, About page (complete)
|
||||
|
||||
- `GET /api/v1/demo/manifest` (unauthenticated): single source of truth for demo org
|
||||
identity, synthetic-data flag, reset allowance/timestamp/anchor date, guide
|
||||
availability, and the 5 named scenarios with **live** readiness (queries the actual
|
||||
`BK-DEMO-RETURN`/`DQ-DEMO-DUPLICATE`/`DQ-DEMO-OVERLAP`/seeded-failed-event/knowledge-
|
||||
provider records — not hardcoded), plus plain-language integration summaries. Backed by
|
||||
new `backend/app/services/demo_manifest.py`. Refactored the n8n status derivation out of
|
||||
`integration_status.py` into a shared `services/integration_status.py` so the manifest
|
||||
and the existing authenticated `/integrations/status` endpoint reuse one implementation.
|
||||
- New settings (`backend/app/core/config.py`, wired through `compose.yaml`/`.env.example`):
|
||||
`DEMO_ORGANIZATION_NAME` (default "Northstar Mobility" — surfaces the project's already-
|
||||
locked fictitious tenant, previously only used internally as the `ragcore_tenant` slug),
|
||||
`DEMO_TIMEZONE`, `DEMO_ALLOW_RESET` (a safety valve — `false` makes `POST
|
||||
/api/v1/demo/reset` return 403 regardless of role; the now-dead `demo_today` setting
|
||||
removed in Batch 1 stays removed).
|
||||
- Rewrote `Login.tsx` in Dutch: names the fictional org, one-sentence explanation sourced
|
||||
from the manifest, no password shown/copyable anywhere, "Start begeleide demo" primary
|
||||
CTA (logs in as Operations Manager, navigates to `/dashboard?guide=start` for task #31 to
|
||||
consume) plus "Verken als Operations Manager"/"Verken als Rental Employee" secondary
|
||||
actions. Added a permanent demo badge (topbar pill + popover: synthetic notice,
|
||||
"workflows are real" reassurance, last-reset timestamp, link to `/about`) replacing the
|
||||
old full-width static `.demo-banner` bar — subtle by design per the brief, not a warning
|
||||
bar. New `/about`
|
||||
page (`AboutDemo.tsx`) covering the fictional problem, what's really implemented, what's
|
||||
synthetic, honest per-integration labels (via the manifest), and a reset pointer —
|
||||
reachable from the badge popover, not added to primary nav (preserves the existing Control
|
||||
Rail nav per the "not a redesign" constraint). Frontend nav/design otherwise untouched.
|
||||
- Evidence: `pytest` **127 passed**, `ruff check .` clean, `mypy app` clean (48 files);
|
||||
frontend `tsc -b` clean, `npm run build` clean; full Playwright suite **41 passed**
|
||||
(37 existing + 4 new `demo-entry.spec.ts` covering entry copy/no-password, guided-demo
|
||||
login redirect, badge popover content + About link, and Escape/outside-click close).
|
||||
Updated stale English login-button aria-labels and login-copy assertions across the
|
||||
existing specs to match the new Dutch copy.
|
||||
- Deployed to Unraid (commit `ac427f4`): pushed to Gitea, `git archive` tarball extracted
|
||||
preserving `.env`/volumes, both `api` and `web` rebuilt (frontend changed this batch),
|
||||
both healthy, migrations unchanged, reseeded. Live-verified: `GET
|
||||
/api/v1/demo/manifest` returns `organization_name: "Northstar Mobility"`,
|
||||
`allow_reset: true`, and all 5 scenarios `ready: true` right after reset. Ran
|
||||
`demo-entry.spec.ts` (4 tests) and the five-minute demo script directly against
|
||||
`http://192.168.10.150:1236` — **5/5 passed**. Reseeded again afterward to leave the
|
||||
server demo-ready.
|
||||
- Exact next action: Demo Guide (collapsible panel, 8 steps) + scenario overview (5 cards
|
||||
on the dashboard, consuming `/api/v1/demo/manifest`'s `scenarios` array) — task #31.
|
||||
|
||||
### Batch 3 — Demo Guide + scenario overview (complete)
|
||||
|
||||
- New `/scenarios` page (`Scenarios.tsx`): all 5 named scenarios as cards (title,
|
||||
operational problem, duration, required role(s), "toont aan", ready/blocked status
|
||||
from the manifest, "Start scenario" linking to the live `start_path`). Dashboard gets
|
||||
one compact "Probeer een demonstratiescenario" panel (not 5 more cards — keeps the
|
||||
existing dashboard uncluttered per the brief) showing readiness count and, for
|
||||
Operations Managers, a guide resume/start control.
|
||||
- New Demo Guide: `DemoGuideContext` (sessionStorage-persisted `currentIndex`/`completed`
|
||||
set — browser-only, never touches auth or business logic), 8 static steps
|
||||
(`data/demoGuideSteps.ts`) each with what-you'll-see/why/start-action/expected-outcome,
|
||||
resolving live routes from the manifest for the two scenario-backed steps (return,
|
||||
duplicate-merge) so they can't drift from actual records. `DemoGuide.tsx` renders a
|
||||
fixed side panel (desktop) that becomes a bottom sheet at ≤700px via CSS only (no
|
||||
layout duplication); `DemoGuideTrigger` (topbar, Operations-Manager-only — the 8 steps
|
||||
require OM throughout) shows a live `completed/8` pill. "Demo opnieuw voorbereiden"
|
||||
calls the real reset endpoint, resets guide progress, and returns to `/login` (mirrors
|
||||
the existing sidebar reset flow). Login's "Start begeleide demo" logs in as OM and
|
||||
passes a one-shot `?guide=start` marker the dashboard consumes once then strips.
|
||||
- Fixed a real regression caught by the responsive-overflow tests: the new topbar guide
|
||||
trigger pushed `.topbar-meta` past the viewport at ≤420px; fixed by hiding the guide
|
||||
trigger (icon+pill) at that breakpoint — the Dashboard's own "Start demo-gids" control
|
||||
remains reachable there. Also fixed a genuine mobile overflow in the new
|
||||
`.demo-start-panel` (flex items without `min-width:0`/wrap on narrow screens).
|
||||
- Fixed one fragile new test (asserted on the transient `?guide=start` URL param, which
|
||||
the app intentionally strips immediately — changed to assert the guide's actual open
|
||||
state instead) and two Playwright strict-mode ambiguous-match errors; confirmed the
|
||||
full 41+6=47-test suite passes twice in a row after these fixes (ruling out flakiness).
|
||||
- Evidence: frontend `tsc -b` clean, `npm run build` clean; full Playwright suite
|
||||
**47 passed** (41 existing + 6 new `demo-guide.spec.ts`: scenario overview shows 5
|
||||
ready cards after reset, starting a scenario navigates to its fixed record, guide
|
||||
step navigation/jump/close, progress persists across page navigation, guide hidden
|
||||
from Rental Employee, restart-from-guide resets data and returns to login). Backend
|
||||
untouched this batch (no re-run needed; last backend gate was 127 passed/ruff/mypy
|
||||
clean in Batch 2).
|
||||
- Deployed to Unraid (commits `9fff84d`, then `14c2ad3` for a test-only fix): pushed to
|
||||
Gitea, tarball extracted, `web` rebuilt (frontend-only batch), healthy. Live-verified:
|
||||
ran `demo-guide.spec.ts` (6), `demo-entry.spec.ts` (4) and the five-minute demo script
|
||||
directly against `http://192.168.10.150:1236` — **11/11 passed**. Caught and fixed one
|
||||
real environment-sensitive test bug in the process: two tests navigated straight to
|
||||
`/scenarios` right after a login click without waiting for the `/dashboard` redirect,
|
||||
which raced harmlessly on localhost but flaked against Unraid's higher latency — fixed
|
||||
by asserting the redirect first, no app-code change needed. Reseeded afterward to leave
|
||||
the server demo-ready.
|
||||
- Exact next action: layer plain-language Dutch explanation onto the return flow, the 5
|
||||
data-quality panels, and fix the knowledge assistant's RAGcore-naming bug — task #33.
|
||||
|
||||
### Batch 4 — return/data-quality/knowledge demo legibility (complete)
|
||||
|
||||
- **Fixed a real honesty bug**: `Knowledge.tsx` named "RAGcore" in the body copy and the
|
||||
retrieval-flow diagram even though the active provider is the demo TF-IDF one (the
|
||||
small badge below was already honest, contradicting the prose one line above). Now
|
||||
derives a `providerLabel` ("Demo knowledge base" vs "RAGcore") from the real health
|
||||
check and uses it everywhere; added an explicit disclosure note when not RAGcore.
|
||||
Added 4 suggested-question chips. **Discovered and fixed a second real bug in the
|
||||
process**: the brief's suggested Dutch questions (and my own Demo Guide step 6 wording)
|
||||
would have returned "insufficient evidence" against the demo provider, because the
|
||||
indexed procedures are English-only — verified empirically (Dutch question →
|
||||
`insufficient`, its English equivalent → `grounded`). Fixed by keeping suggested
|
||||
questions in English (matching the indexed content) and rewording the Guide step to
|
||||
explain the knowledge base is English, rather than mistranslating the demo's
|
||||
centerpiece feature into silently returning wrong answers.
|
||||
- Return flow: `BookingDetail.tsx` now detects the one named return-anomaly scenario
|
||||
booking (via the manifest, not a hardcoded ref) and fetches that vehicle's real
|
||||
canonical odometer to pre-fill `ReturnForm`'s "End odometer" field with a suspicious
|
||||
value below it, plus a callout explaining why — the brief explicitly requires the demo
|
||||
not ask a visitor to invent a suspicious number themselves. Scoped narrowly to that one
|
||||
scenario booking; ordinary returns are unaffected. `ReturnResultPanel` now links to
|
||||
Automation and Audit trail (previously only the vehicle), and shows a "Ga verder met de
|
||||
demo" button when the Demo Guide is open (advances the guide and navigates to the next
|
||||
step). **Fixed a real regression caught by the existing return-review e2e test**: the
|
||||
async pre-fill could silently overwrite odometer text a visitor had already started
|
||||
typing, if the vehicle-detail fetch resolved after they began typing — fixed with an
|
||||
`odometerEditedByUser` ref guard.
|
||||
- Data quality: added a shared `RuleExplainer` (what's wrong / why it matters, in plain
|
||||
language) for all 5 rule types on `DataQualityIssueDetail.tsx`; added a generic
|
||||
post-resolution confirmation (audit-trail link, vehicle link, "Ga verder met de demo")
|
||||
for the 4 rule types that previously just silently flipped their status badge with no
|
||||
explicit confirmation, and extended `VehicleStatusConflictPanel`'s existing confirmation
|
||||
with the same links rather than duplicating it. Added a "Demo scenario's only" checkbox
|
||||
filter on `DataQuality.tsx` (client-side `public_ref.startsWith("DQ-DEMO-")`, no new
|
||||
business logic) so the curated issues are easy to find among the full queue.
|
||||
- Evidence: frontend `tsc -b` clean, `npm run build` clean; full Playwright suite
|
||||
**51 passed** (47 existing + 4 new `demo-legibility.spec.ts`: return pre-fill + why-
|
||||
suspicious explanation + result links, rule explainer visible, demo-scenario filter
|
||||
narrows correctly, knowledge suggested question returns grounded evidence with the
|
||||
correct provider label). Backend untouched this batch.
|
||||
- Deployed to Unraid (commit `ddc3a98`): pushed to Gitea, tarball extracted, `web`
|
||||
rebuilt (frontend-only), healthy. Live-verified: ran `demo-legibility.spec.ts` (4) and
|
||||
the five-minute demo script directly against `http://192.168.10.150:1236` —
|
||||
**5/5 passed**. Reseeded afterward to leave the server demo-ready.
|
||||
- Exact next action: plain-language integration-status labels, richer audit narration,
|
||||
the full "Over deze demo" page content (currently a first pass from Batch 2), and
|
||||
wiring reset into the guide/About/OM menu narrative — task #34.
|
||||
|
||||
### Batch 5 — integration-status UX, audit UX, About page, reset integrity (complete)
|
||||
|
||||
- Plain-language integration status: extracted `frontend/src/data/integrationLabels.ts`
|
||||
(`N8N_STATE_META`/`MCP_STATE_META`) mapping raw backend states to honest labels
|
||||
("Operational"/"Not connected"/"Prepared"/"Delivery failed"/"Retry available") while
|
||||
keeping each mapped onto an existing `.status-*` CSS colour class (a few raw values like
|
||||
`degraded`/`disabled`/`configured` had no matching CSS rule at all before this — a real,
|
||||
pre-existing colour-coding gap). `StatusBadge` gained an optional `label` override prop
|
||||
(backward compatible) so the badge's colour class and its displayed text can differ.
|
||||
Wired into both `Automation.tsx` and `Dashboard.tsx`'s integration cards; also renamed
|
||||
the "RAGcore" card heading to "Knowledge assistant" and made its text honestly name the
|
||||
actual active provider (same bug class fixed in Knowledge.tsx in Batch 4).
|
||||
- Audit trail: added a "Follow-up" column with a "View related events" action per row that
|
||||
filters the same list by `correlation_id` (reuses the backend's existing, already-tested
|
||||
`correlation_id` query param — no new business logic), with a "Clear this filter"
|
||||
affordance. This is how a visitor sees "what else happened as a result of this action"
|
||||
(e.g. a return's linked vehicle-status-changed / workflow-queued events) without a
|
||||
bigger grouped-timeline rebuild.
|
||||
- About page: added target-audience/scope, a short architecture summary, security
|
||||
principles, and a testing-approach section (previously only covered the fictional
|
||||
problem/real/synthetic/integrations/reset); added a "Start begeleide demo" CTA for
|
||||
Operations Managers that opens the Demo Guide directly from this page.
|
||||
- Reset integrity: added `scenario_integrity_report()` (`backend/app/services/
|
||||
demo_manifest.py`), reusing the exact same scenario-readiness derivation the manifest
|
||||
and scenario overview already use (so it can't drift), and wired it into `POST
|
||||
/api/v1/demo/reset` — both the response body and the `demo_reset` audit event's
|
||||
metadata now carry `scenario_integrity: {all_ready, not_ready}`. This is the
|
||||
server-side post-reset integrity check the brief asks for; visible today via the audit
|
||||
event's raw-detail view, satisfying the requirement without adding a UI banner to a
|
||||
flow that immediately logs the user out and redirects to `/login`.
|
||||
- **Fixed a second real regression this batch, caught by the existing return-review
|
||||
e2e test**: restructured the odometer pre-fill so `BookingDetail.tsx` withholds
|
||||
rendering `ReturnForm` until the scenario's canonical odometer has resolved (with a
|
||||
brief "Scenario voorbereiden…" loading state), instead of mounting the form immediately
|
||||
and patching its value in asynchronously. The previous approach raced visibly with
|
||||
Playwright's `fill()` (and would have raced with a real visitor typing quickly),
|
||||
producing a corrupted concatenated value in one observed failure. This also let the
|
||||
now-unnecessary `odometerEditedByUser` ref guard be removed — simpler and more robust
|
||||
than the effect-based patch it replaced.
|
||||
- Evidence: `pytest` **127 passed**, `ruff check .` clean, `mypy app` clean (48 files);
|
||||
frontend `tsc -b` clean, `npm run build` clean; full Playwright suite **51 passed**,
|
||||
confirmed stable across three consecutive full runs (given how many timing races this
|
||||
batch and the previous one surfaced, stability was verified deliberately rather than
|
||||
assumed from a single green run).
|
||||
- Deployed to Unraid (commit `5fa4fe0`): pushed to Gitea, tarball extracted, both `api`
|
||||
and `web` rebuilt, healthy, migrations unchanged at `e7b08389f47f (head)`, reseeded.
|
||||
Live-verified: ran `demo-legibility.spec.ts` (4), the five-minute demo script, and the
|
||||
full `interactive-elements.spec.ts` suite (26) directly against
|
||||
`http://192.168.10.150:1236` — **31/31 passed**. Reseeded afterward to leave the
|
||||
server demo-ready.
|
||||
- Exact next action: full guided-demo Playwright test + remaining targeted demo tests per
|
||||
section 19 (mobile guide, keyboard nav, all scenario flows, About page, accessibility/
|
||||
reduced-motion/console/network checks) — task #35.
|
||||
|
||||
### Batch 6 — full guided-demo test + targeted demo tests (complete)
|
||||
|
||||
- **Found and fixed a real, fairly serious desktop layout bug** while writing the full
|
||||
guided-demo test: the Demo Guide's fixed right-side panel (400px wide) overlapped the
|
||||
main content area at normal desktop widths with no reflow, so its own step-list buttons
|
||||
intercepted pointer events meant for the page underneath (concretely: the return form's
|
||||
"Review return" button was unclickable while the guide was open, at exactly the
|
||||
viewport size Playwright's default test browser uses — this would have hit real
|
||||
visitors on ordinary laptop screens too). Fixed by adding a `guide-open` class to
|
||||
`.app-workspace` that reserves `padding-right: min(400px, 92vw)` while the guide is open
|
||||
(≥701px only; the ≤700px bottom-sheet layout is unaffected), so content reflows aside
|
||||
instead of sitting underneath the panel.
|
||||
- Added `frontend/e2e/guided-demo-full.spec.ts`: one comprehensive test walking a fresh
|
||||
Operations Manager session through all 8 Demo Guide steps in order, performing the
|
||||
**real** action at each step (not just verifying copy) — processes the actual
|
||||
odometer-anomaly return, resolves the resulting data-quality issue, merges the
|
||||
duplicate customer, asks a suggested knowledge question, checks automation + audit,
|
||||
reviews the About page — using the guide's own progression controls
|
||||
("Volgende"/"Ga naar deze stap"/"Ga verder met de demo") throughout, then resets the
|
||||
demo data again at the end to restore the environment per the brief's requirement.
|
||||
- Added `frontend/e2e/demo-accessibility.spec.ts` (4 tests): the guide renders as a
|
||||
correctly-anchored bottom sheet on a 390px mobile viewport with no horizontal overflow;
|
||||
the guide never covers the return form's action buttons on desktop (regression test for
|
||||
the bug above); the demo badge and guide trigger are keyboard-focusable and operable
|
||||
(Enter to open, explicit close controls); key demo pages (dashboard, scenarios, about,
|
||||
guide open) load with no unexpected console errors (the one expected benign 401 from
|
||||
the app's own session-probe on first load is explicitly allow-listed, not silenced
|
||||
blindly).
|
||||
- Evidence: full Playwright suite **56 passed** (51 existing + 1 guided-demo-full + 4
|
||||
demo-accessibility), confirmed stable across two consecutive full runs. Backend
|
||||
untouched this batch (last gate: 127 passed/ruff/mypy clean, Batch 5).
|
||||
- Deployed to Unraid (commit `07d5605`): pushed to Gitea, tarball extracted, `web`
|
||||
rebuilt (frontend-only), healthy, reseeded. Live-verified: ran
|
||||
`guided-demo-full.spec.ts` and `demo-accessibility.spec.ts` directly against
|
||||
`http://192.168.10.150:1236` — **5/5 passed**, confirming the desktop-overlay layout
|
||||
fix holds on the real deployment too. Reseeded afterward to leave the server
|
||||
demo-ready.
|
||||
- Exact next action: clean-checkout demo drill, final documentation set (demo-concept/
|
||||
demo-scenarios/demo-data/demo-guide/demo-runbook, README, .env.example), final Unraid
|
||||
deploy + live evidence with screenshots, `artifacts/demo-release/final-summary.md` —
|
||||
task #36 (final).
|
||||
|
||||
### Batch 7 (final) — clean-checkout drill, docs, final Unraid evidence (complete)
|
||||
|
||||
- **Clean-checkout drill**: fresh `git clone` into an isolated scratch directory,
|
||||
isolated Compose project (`mobilityops-cleandrill`) + remapped ports via
|
||||
`compose.override.yaml`, `up --build -d` from empty volumes. Migrations ran
|
||||
automatically to `e7b08389f47f (head)`; seeded; full backend gate **127 passed**,
|
||||
ruff/mypy clean; `npm ci` clean (same pre-existing advisories as before, unchanged);
|
||||
`tsc -b`/`vite build` clean; full Playwright suite **56 passed** against the isolated
|
||||
stack; reseeded and confirmed all 5 scenarios `ready: true` via the manifest; torn down
|
||||
(`down -v` on the isolated project only — the working dev stack was untouched
|
||||
throughout).
|
||||
- Added the full demo-release documentation set: `docs/demo-release/demo-concept.md`,
|
||||
`demo-scenarios.md`, `demo-data.md`, `demo-guide.md`, `demo-runbook.md`; updated
|
||||
`README.md` (current test counts, links to the new docs, a "Demo" section) and
|
||||
`docs/17-runbook.md` (cross-reference to the demo-specific runbook).
|
||||
- Added `frontend/e2e/_capture-demo-screenshots.spec.ts` (tooling, excluded from the
|
||||
regular suite) and captured 17 evidence screenshots live against
|
||||
`http://192.168.10.150:1236` into `artifacts/demo-release/screenshots/`.
|
||||
- Final live acceptance: full Playwright suite re-run against the live server —
|
||||
**56 passed**; `docker compose ps` on the server shows `api`/`db`/`web` all healthy;
|
||||
`docker logs` for `api`/`web` show no errors; reseeded to leave the server
|
||||
demo-ready after evidence capture.
|
||||
- Wrote `artifacts/demo-release/final-summary.md` with the full required evidence
|
||||
(branches/commits, org/roles/guide/scenarios, seed/date-anchor/reset strategy, real vs.
|
||||
synthetic vs. not-connected, all test results, clean-checkout result, deployment/
|
||||
health/console/log results, responsive/accessibility results including the two real
|
||||
layout bugs found and fixed this work (mobile topbar overflow in Batch 3, desktop
|
||||
guide-panel overlap in Batch 6), known limitations, 5-/10-minute demo flows, redeploy/
|
||||
rollback commands, and the screenshot list).
|
||||
- Demo-productization work on this branch is complete. Every task (#29–#36) is done;
|
||||
every batch was tested locally, deployed to Unraid, and re-verified live before moving
|
||||
to the next. See `artifacts/demo-release/final-summary.md` for the definitive
|
||||
acceptance evidence.
|
||||
|
||||
@@ -12,18 +12,36 @@ design decision and visual evidence.
|
||||
|
||||
All people, companies, vehicles, bookings and documents are synthetic. The workflows, validation, integrations, audit logging and access boundaries are intended to be real.
|
||||
|
||||
## Demo
|
||||
|
||||
The demo presents itself as **Northstar Mobility**, a fictitious Belgian camper/van
|
||||
rental company — the login screen, a permanent "Synthetische demo" indicator, an in-app
|
||||
guided tour (Demo Guide), a curated `/scenarios` overview, and an "Over deze demo" page
|
||||
all make the fictional context, synthetic-data status, and real-vs-simulated boundaries
|
||||
explicit without any verbal explanation. See `docs/demo-release/` for the full demo
|
||||
concept, the five named scenarios, the seed/date-anchoring strategy, the guided-tour
|
||||
design, and the operational runbook (5-minute and 10-minute demo flows, reset, redeploy,
|
||||
rollback).
|
||||
|
||||
## Scope
|
||||
|
||||
The PoC implements:
|
||||
|
||||
- operations dashboard;
|
||||
- vehicle and booking views;
|
||||
- one complete vehicle-return workflow;
|
||||
- five deterministic data-quality checks;
|
||||
- operations dashboard with a truthful aggregate n8n/MCP integration-status card;
|
||||
- vehicle and booking views with working search, filters and pagination;
|
||||
- server-backed session lifecycle (refresh-safe, central 401 handling);
|
||||
- a role matrix enforced server-side and mirrored in the UI (see
|
||||
`docs/12-security-and-audit.md`);
|
||||
- vehicle return capture → authoritative server-evaluated review → commit → result;
|
||||
- five deterministic data-quality checks, each with a bounded resolution flow, plus a
|
||||
manual scan action;
|
||||
- human review and customer merge;
|
||||
- audit trail;
|
||||
- audit trail with human-readable before/after evidence and safe entity links;
|
||||
- role-aware global search across vehicles, bookings and (Operations Manager) issues;
|
||||
- safe, confirmed demo reset;
|
||||
- RAGcore-backed knowledge assistant with citations;
|
||||
- one n8n return-processing workflow;
|
||||
- two n8n workflows: return processing, and a scheduled data-quality scan with
|
||||
crash-recoverable outbox delivery leases;
|
||||
- four read-only MCP tools through ITWorx MCP Hub;
|
||||
- deterministic demo reset and five-minute showcase.
|
||||
|
||||
@@ -33,18 +51,26 @@ It is not an ERP, CRM, accounting package, public booking site, payment system o
|
||||
|
||||
- **n8n**: fully implemented and verified against a real n8n instance, including
|
||||
degraded mode (n8n stopped mid-flow → return still commits, event stays `pending`
|
||||
with backoff, self-heals once n8n returns) and the failed-delivery manual-retry path.
|
||||
with backoff, self-heals once n8n returns), the failed-delivery manual-retry path,
|
||||
stale-delivery-lease recovery after a simulated crash, and a second (scheduled
|
||||
quality-scan) workflow live-verified end to end against a real n8n instance.
|
||||
`GET /api/v1/integrations/status` reports a truthful aggregate state from outbox
|
||||
delivery counts, not just the most recent event.
|
||||
- **RAGcore**: the demo `KnowledgeProvider` (deterministic TF-IDF extractive retrieval
|
||||
over the local procedure documents) is what satisfies the knowledge-assistant
|
||||
acceptance criteria and is fully verified. A `RAGcoreKnowledgeProvider` HTTP adapter is
|
||||
implemented and unit-tested, including its unavailable-degradation path, but was never
|
||||
exercised against a live RAGcore instance in this environment.
|
||||
- **ITWorx MCP Hub**: the four read-only provider endpoints are implemented, tested, and
|
||||
directly `curl`-verified with correct auth enforcement and audit logging. No live Hub
|
||||
instance was reachable in this environment to verify an actual Hub round trip.
|
||||
directly `curl`-verified with correct auth enforcement and audit logging.
|
||||
`MCP_HUB_REGISTRATION_ENABLED` is now actually wired into `Settings` (it was previously
|
||||
declared in `.env.example` but silently dropped) and reported honestly by the
|
||||
integration-status endpoint. No live Hub instance was reachable in this environment to
|
||||
verify an actual Hub round trip.
|
||||
|
||||
See `artifacts/final-acceptance/summary.md` for full verification evidence and exact
|
||||
commands.
|
||||
See `artifacts/functional-completion/final-summary.md` for the functional-completion
|
||||
audit evidence (supersedes the design-validation summary below for integration status),
|
||||
and `artifacts/final-acceptance/summary.md` for the original M0–M7 acceptance evidence.
|
||||
|
||||
## Repository map
|
||||
|
||||
@@ -60,6 +86,11 @@ commands.
|
||||
- `frontend/` — React/TypeScript/Vite web app, including the Playwright end-to-end suite (`frontend/e2e/`).
|
||||
- `artifacts/evidence/` — final acceptance evidence (screenshots, architecture, `final-summary.md`).
|
||||
- `artifacts/design-validation/` — baseline audit, Stitch direction references and implemented responsive captures.
|
||||
- `docs/functional-completion/` — the functional-completion audit and pre-work server baseline.
|
||||
- `artifacts/functional-completion/` — functional-completion acceptance evidence.
|
||||
- `docs/demo-release/` — demo concept, scenarios, seed/date-anchoring strategy, guided
|
||||
tour, and runbook.
|
||||
- `artifacts/demo-release/` — demo-productization acceptance evidence.
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -83,9 +114,9 @@ All defaults are configurable via `.env` (see `.env.example`).
|
||||
## Quality gates
|
||||
|
||||
```bash
|
||||
make test # backend: pytest (66 tests)
|
||||
make test # backend: pytest (127 tests)
|
||||
make lint # backend: ruff + mypy (strict, zero errors)
|
||||
make e2e # frontend: Playwright end-to-end (18 tests, live stack required)
|
||||
make e2e # frontend: Playwright end-to-end (56 tests, live stack required)
|
||||
```
|
||||
|
||||
Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`).
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# Demo-productization final summary
|
||||
|
||||
## Branches and commits
|
||||
|
||||
- **Gitea repository**: `ssh://git@192.168.10.150:222/Jens/MobilityOps.git` (browsable at
|
||||
`http://192.168.10.150:3000/Jens/MobilityOps`)
|
||||
- **Branch**: `feat/mobilityops-functional-completion` (no new branch created; no merge
|
||||
to `main`; no rebase/reset/squash/force-push; full git history preserved, as required)
|
||||
- **Start commit** (functional-completion baseline, already accepted):
|
||||
`e0c7ed60112510687627d20a957af91c8b9db7f8`
|
||||
- **Final commit**: `4a268c73515dc4f1d56c1aa2f231714654bffbb8` — verified via
|
||||
`git rev-parse HEAD` on `feat/mobilityops-functional-completion` and confirmed to match
|
||||
`/mnt/user/appdata/mobilityops/.deploy/source-revision` on the Unraid server exactly.
|
||||
(This corrects a self-reference gap in the immediately preceding pair of commits, which
|
||||
necessarily could not know their own hash at the time they were written; this is now
|
||||
the single, unambiguous, verified reference. The repository's primary branch is
|
||||
`master`, not `main` — no branch named `main` exists in this repository.)
|
||||
- **Live URL**: `http://192.168.10.150:1236`
|
||||
|
||||
## Demo organisation and context
|
||||
|
||||
**Northstar Mobility** — a fictitious Belgian camper/van rental company (~50 vehicles,
|
||||
one main location, rental team, an Operations Manager, a small workshop). This name was
|
||||
already a locked internal decision (`ragcore_tenant: northstar-mobility-demo`,
|
||||
`PROJECT_STATE.md`'s "Locked decisions") before this work — this pass surfaces it in the
|
||||
UI rather than inventing it. Full concept: `docs/demo-release/demo-concept.md`.
|
||||
|
||||
## Roles
|
||||
|
||||
- **Operations Manager** — full access: data-quality resolution, workflow retries, audit
|
||||
trail, demo reset, the Demo Guide.
|
||||
- **Rental Employee** — scoped access: bookings, returns, fleet, knowledge assistant.
|
||||
|
||||
Both are reachable from the login screen with no password.
|
||||
|
||||
## Demo Guide
|
||||
|
||||
An 8-step, sessionStorage-persisted guided tour (Operations-Manager-only, since every
|
||||
step requires that role). Full design: `docs/demo-release/demo-guide.md`. Steps: (1)
|
||||
understand operational state, (2) open the booking needing attention, (3) process the
|
||||
odometer-anomaly return, (4) handle the created data-quality issue, (5) merge the
|
||||
duplicate customer, (6) ask the knowledge assistant, (7) check automation + audit, (8)
|
||||
review real vs. synthetic vs. not-connected.
|
||||
|
||||
## Scenarios (all 5, full detail in `docs/demo-release/demo-scenarios.md`)
|
||||
|
||||
| # | Scenario | Fixed records | Role |
|
||||
|---|---|---|---|
|
||||
| 1 | Odometer regression on return | `BK-DEMO-RETURN` / `MO-024` | Either |
|
||||
| 2 | Possible duplicate customer | `CUS-0012` / `CUS-0178` / `DQ-DEMO-DUPLICATE` | OM |
|
||||
| 3 | Overlapping bookings | `MO-016` / `BK-DEMO-OVERLAP-A/B` / `DQ-DEMO-OVERLAP` | OM |
|
||||
| 4 | Failed automation, retried | outbox event `...020` / `BK-H-0020` | OM |
|
||||
| 5 | Grounded procedure question | (no fixed record; suggested questions) | Either |
|
||||
|
||||
`GET /api/v1/demo/manifest`'s `scenarios` array derives `ready`/`blocked_reason` from the
|
||||
live underlying records, never hardcoded — confirmed via `backend/tests/
|
||||
test_demo_manifest.py` (`test_demo_manifest_scenarios_ready_after_fresh_reset`) and
|
||||
live-checked after every reset throughout this work.
|
||||
|
||||
## Seed strategy and date-anchoring
|
||||
|
||||
`seed/generate_seed.py --anchor 2026-08-01 --seed 20260801` produces deterministic CSVs
|
||||
with absolute timestamps authored against a fixed anchor. `backend/app/seed_loader.py`
|
||||
shifts every seeded datetime by `(real today − authored anchor)` on every seed/reset, so
|
||||
"today"/"near-future"/"currently overlapping" scenarios stay true to the actual reset
|
||||
moment instead of decaying. This fixed a real, confirmed bug (`BK-DEMO-RETURN` was found
|
||||
sitting 2 days in the past before this fix). Full detail: `docs/demo-release/demo-data.md`.
|
||||
|
||||
## Reset strategy
|
||||
|
||||
`POST /api/v1/demo/reset` (Operations Manager only, gated by `DEMO_ALLOW_RESET`) clears
|
||||
MobilityOps's own tables, reseeds with a fresh date anchor, re-runs the data-quality scan,
|
||||
and runs a server-side scenario-integrity check (`scenario_integrity_report()`) recorded
|
||||
in both the response and the `demo_reset` audit event. Reachable from the sidebar, the
|
||||
Demo Guide, and the About page. Never touches shared n8n/RAGcore/MCP data, other
|
||||
containers, or volumes.
|
||||
|
||||
## Real vs. synthetic vs. not-connected
|
||||
|
||||
See `docs/demo-release/demo-concept.md` for the full breakdown. In short: auth/roles,
|
||||
vehicle/booking management, return preview/commit, the 5 data-quality rules and their
|
||||
resolutions, the audit trail, n8n orchestration, Docker deployment, and the automated
|
||||
test suite are all really implemented. The organisation, all people, vehicles, bookings,
|
||||
procedures, and the 5 named scenarios are synthetic. RAGcore and the ITWorx MCP Hub are
|
||||
not live-connected (honestly labelled "Demomodus"/"Niet gekoppeld" everywhere, never a
|
||||
fabricated success).
|
||||
|
||||
## Test results
|
||||
|
||||
### Backend (clean checkout, isolated stack)
|
||||
- `pytest`: **127 passed**
|
||||
- `ruff check .`: clean
|
||||
- `mypy app`: clean (48 source files)
|
||||
|
||||
### Frontend (clean checkout, isolated stack)
|
||||
- `npm ci`: clean (pre-existing esbuild-moderate/react-router-RSC-high advisories,
|
||||
unchanged from before this work — not introduced by it)
|
||||
- `tsc -b`: clean
|
||||
- `npm run build`: clean
|
||||
- Full Playwright suite: **56 passed** (against the isolated clean-checkout stack)
|
||||
|
||||
### Guided-demo test
|
||||
`frontend/e2e/guided-demo-full.spec.ts` — one comprehensive test walking a fresh
|
||||
Operations Manager session through all 8 Demo Guide steps performing the real action at
|
||||
each step (processes the actual odometer-anomaly return, resolves the resulting
|
||||
data-quality issue, merges the duplicate customer, asks a suggested knowledge question,
|
||||
checks automation + audit, reviews the About page), then resets the demo data again to
|
||||
restore the environment. **Passed**, confirmed stable across repeated runs both locally
|
||||
and against the live Unraid deployment.
|
||||
|
||||
### Clean-checkout drill
|
||||
Fresh `git clone` of this branch/commit into an isolated scratch directory, `.env` from
|
||||
`.env.example`, isolated Compose project name (`mobilityops-cleandrill`) and remapped
|
||||
host ports (`compose.override.yaml` with `!override` merge tags — no shared state with
|
||||
any other stack), `docker compose up --build -d` from empty volumes → migrations ran
|
||||
automatically (`e7b08389f47f (head)`) → seeded → full backend gate (127 passed, ruff/
|
||||
mypy clean) → `npm ci`/`tsc -b`/`vite build` clean → full Playwright suite (56 passed)
|
||||
→ reseeded and confirmed all 5 scenarios `ready: true` via the manifest → torn down
|
||||
(`docker compose down -v` on the isolated project only; the working dev stack was never
|
||||
touched).
|
||||
|
||||
### Server deployment
|
||||
Deployed incrementally after every batch (10 deploy cycles across this work); final
|
||||
state: both `api` and `web` rebuilt and healthy at the final commit, `db` untouched
|
||||
across all of them (no destructive migrations on this branch). Migrations at
|
||||
`e7b08389f47f (head)` throughout. `.deploy/source-revision` on the server matches the
|
||||
final commit exactly.
|
||||
|
||||
### Container health
|
||||
`docker compose ps` on the server: `api`, `db`, `web` all `healthy`, no restart loops.
|
||||
|
||||
### Browser console / network
|
||||
No unexpected console errors on login, dashboard, scenarios, About, or with the Demo
|
||||
Guide open (verified via `demo-accessibility.spec.ts`; the one benign 401 from the app's
|
||||
own session-probe on first load is expected and explicitly accounted for, not silenced
|
||||
blindly). No unresolved server errors in `docker logs` for `api`/`web` at the time of
|
||||
this evidence capture.
|
||||
|
||||
### Responsive / accessibility
|
||||
- Demo Guide renders as a correctly-anchored bottom sheet at 390px with no horizontal
|
||||
overflow (`demo-accessibility.spec.ts`).
|
||||
- **Real bug found and fixed**: the Demo Guide's fixed desktop side panel overlapped
|
||||
main content with no reflow, making the return form's "Review return" button
|
||||
unclickable while the guide was open at ordinary desktop widths — this surfaced while
|
||||
writing the full guided-demo test. Fixed via a `guide-open` layout class that reserves
|
||||
space for the panel; regression-tested.
|
||||
- Demo badge and Demo Guide triggers are keyboard-focusable and operable (Enter to open,
|
||||
explicit close controls).
|
||||
- Existing responsive-overflow checks (390/768/1280/1440px) remain green throughout.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- RAGcore and the ITWorx MCP Hub are not live-connected in this environment (by design
|
||||
— see scope). The knowledge assistant uses a local, English-only demo knowledge base;
|
||||
a Dutch question against it returns "insufficient evidence" (verified empirically), so
|
||||
suggested questions and the Demo Guide's step 6 instructions deliberately stay in
|
||||
English rather than silently breaking the demo's centerpiece grounded-answer feature.
|
||||
- Existing operational screens (Dashboard, Vehicles, Bookings, Data Quality workbench,
|
||||
Audit, Automation internals) remain in English; only new demo-productization surfaces
|
||||
(login, Demo Guide, scenario overview, About page, demo badge, plain-language
|
||||
integration labels) are in Dutch — a deliberate, documented scope decision, not an
|
||||
oversight (`docs/demo-release/current-demo-gap-audit.md`, gap #11).
|
||||
- Scenario S3 ("missing inspection before next booking", `MO-031`) is seeded and visible
|
||||
in the attention queue but isn't one of the 5 scenarios surfaced on `/scenarios`,
|
||||
matching the brief's request for exactly 5.
|
||||
|
||||
## 5-minute and 10-minute demo flows
|
||||
|
||||
See `docs/demo-release/demo-runbook.md` for the exact click-through scripts.
|
||||
|
||||
## Redeploy commands and rollback procedure
|
||||
|
||||
See `docs/demo-release/demo-runbook.md` — `git archive` → `scp` → extract → rebuild
|
||||
`api`/`web` → confirm migrations → reseed. Rollback: extract an earlier
|
||||
`.deploy/source-<short-sha>.tar.gz` and update `.deploy/source-revision` to match.
|
||||
|
||||
## Evidence screenshots
|
||||
|
||||
All captured live against `http://192.168.10.150:1236` (`artifacts/demo-release/screenshots/`):
|
||||
|
||||
1. `01-demo-entry-desktop.png` / `02-demo-entry-mobile.png` — demo entry, both sizes
|
||||
2. `03-dashboard-with-scenarios.png` — dashboard with the scenario teaser panel
|
||||
3. `04-demo-guide.png` — the Demo Guide panel open
|
||||
4. `05-return-preview.png` / `06-return-result.png` — the return flow
|
||||
5. `07-data-quality-resolution.png` — a data-quality issue with its plain-language explainer
|
||||
6. `08-duplicate-customer-merge.png` — the duplicate-customer comparison/merge UI
|
||||
7. `09-knowledge-assistant.png` — a grounded answer with cited sources
|
||||
8. `10-integration-status.png` — plain-language integration status on Automation
|
||||
9. `11-automation-retry-before.png` / `11-automation-retry-after.png` — a workflow retry
|
||||
10. `12-audit-trail.png` / `13-audit-related-events.png` — audit trail + correlation drill-down
|
||||
11. `14-about-demo.png` — the About page
|
||||
12. `15-demo-badge-popover.png` — the permanent synthetic-demo badge popover
|
||||
13. `16-reset-confirm.png` — the reset confirmation flow
|
||||
|
||||
No secrets appear in any screenshot or in this document.
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 102 KiB |
@@ -0,0 +1,246 @@
|
||||
# MobilityOps functional-completion — final summary
|
||||
|
||||
## Outcome: PASS
|
||||
|
||||
All achievable functional-completion requirements were audited, implemented, tested
|
||||
locally (including a genuine clean-checkout drill), committed, pushed, deployed to
|
||||
Unraid and re-verified against the live server after every batch.
|
||||
|
||||
## Revisions
|
||||
|
||||
- Starting branch: `design/mobilityops-premium-ui`
|
||||
- Starting/observed commit: `54dc952915a4874fcdf14781e1c37feb0e253851`
|
||||
- Completion branch: `feat/mobilityops-functional-completion`
|
||||
- Final commit: `5b2827eb7e84e40d97c4d025debb2af1246908e0` (this is the parent commit
|
||||
the deploy below targets; the commit that actually records this string is
|
||||
necessarily one commit later — `git log -1` on this branch is the authoritative
|
||||
source of the true HEAD)
|
||||
- Gitea branch URL: `https://gitea.itworx.tech/Jens/MobilityOps` (SSH remote
|
||||
`ssh://git@192.168.10.150:222/Jens/MobilityOps.git`), branch
|
||||
`feat/mobilityops-functional-completion`
|
||||
- Deployed URL: `http://192.168.10.150:1236`
|
||||
- Server deployment directory: `/mnt/user/appdata/mobilityops`
|
||||
- Server Compose project: `mobilityops`
|
||||
|
||||
## Exact commands executed (representative — run after every batch)
|
||||
|
||||
```bash
|
||||
# Local backend gate
|
||||
docker compose exec -T api python -m app.cli seed --reset
|
||||
docker compose exec -T api pytest -q
|
||||
docker compose run --rm api ruff check .
|
||||
docker compose run --rm api mypy app
|
||||
|
||||
# Local frontend gate
|
||||
cd frontend && npx tsc -b --noEmit && npm run build
|
||||
|
||||
# Local e2e (against the local dev stack)
|
||||
npx playwright test
|
||||
|
||||
# Deploy the exact committed revision
|
||||
COMMIT=$(git rev-parse HEAD)
|
||||
git archive --format=tar.gz --output=/tmp/source.tar.gz "$COMMIT"
|
||||
scp -P 22 /tmp/source.tar.gz unraid:/mnt/user/appdata/mobilityops/.deploy/source.tar.gz
|
||||
ssh unraid "cd /mnt/user/appdata/mobilityops && tar -xzf .deploy/source.tar.gz && echo $COMMIT > .deploy/source-revision"
|
||||
ssh unraid "cd /mnt/user/appdata/mobilityops && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web"
|
||||
ssh unraid "cd /mnt/user/appdata/mobilityops && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api alembic current"
|
||||
ssh unraid "cd /mnt/user/appdata/mobilityops && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m app.cli seed --reset"
|
||||
|
||||
# e2e against the live server
|
||||
MOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236 npx playwright test
|
||||
```
|
||||
|
||||
Clean-checkout drill (once, section 14):
|
||||
|
||||
```bash
|
||||
git clone --branch feat/mobilityops-functional-completion \
|
||||
<repo> /tmp/mobilityops-clean-checkout
|
||||
cd /tmp/mobilityops-clean-checkout
|
||||
cp .env.example .env
|
||||
docker compose -p mobilityops-clean up --build -d # isolated project name/ports
|
||||
docker compose -p mobilityops-clean exec -T api alembic current
|
||||
docker compose -p mobilityops-clean exec -T api python -m app.cli seed --reset
|
||||
docker compose -p mobilityops-clean exec -T api pytest -q
|
||||
docker compose -p mobilityops-clean run --rm api ruff check .
|
||||
docker compose -p mobilityops-clean run --rm api mypy app
|
||||
cd frontend && npm ci && npx tsc -b --noEmit && npm run build
|
||||
MOBILITYOPS_PUBLIC_URL=http://localhost:11228 npx playwright test
|
||||
docker compose -p mobilityops-clean down -v # isolated project only
|
||||
```
|
||||
|
||||
## Test and validation results
|
||||
|
||||
| Gate | Local dev stack | Clean-checkout (isolated) | Live Unraid |
|
||||
|---|---|---|---|
|
||||
| `pytest` | 117 passed | 117 passed | — (not applicable; no test runner on the review host) |
|
||||
| `ruff check .` | clean | clean | — |
|
||||
| `mypy app` | 0 issues / 46 files | 0 issues / 46 files | — |
|
||||
| `npx tsc -b` | clean | clean | — |
|
||||
| `npm run build` | clean | clean | — |
|
||||
| `npx playwright test` | 37 passed | 37 passed | **37 passed** (against `http://192.168.10.150:1236`) |
|
||||
| `npm audit` | 4 known advisories (unchanged — see Known limitations) | same | — |
|
||||
|
||||
Every batch (1 through 5) was deployed and re-verified with the full 37-test Playwright
|
||||
suite against the live server before moving to the next batch, not only at the end.
|
||||
|
||||
## Application URLs and ports
|
||||
|
||||
| Service | URL / port | Notes |
|
||||
|---|---|---|
|
||||
| Web (Unraid) | `http://192.168.10.150:1236` | only MobilityOps-owned service exposed on the LAN |
|
||||
| API (Unraid) | Compose-network only | reached through the web nginx `/api/` proxy |
|
||||
| PostgreSQL (Unraid) | Compose-network only | never exposed |
|
||||
| Shared n8n (Unraid) | `http://192.168.10.150:5678` | pre-existing host infrastructure, outside the MobilityOps Compose project |
|
||||
| Web (local dev) | `http://localhost:1228` | |
|
||||
| API (local dev) | `http://localhost:8128` | |
|
||||
| n8n (local dev) | `http://localhost:5678` | bundled, `bundled-n8n` profile |
|
||||
|
||||
## Demo users and access method
|
||||
|
||||
Two fixed seeded identities, selected via the login screen's role buttons (no
|
||||
password): **Amelie De Ridder** (`USR-OPS`, Operations Manager) and **Karim
|
||||
Boujaddaine** (`USR-EMP`, Rental Employee). `POST /api/v1/demo/login` issues an
|
||||
HttpOnly, `SameSite=Lax` signed session cookie; `GET /api/v1/demo/session` (marked
|
||||
`Cache-Control: no-store`) is what the browser actually trusts on every load, not a
|
||||
locally cached copy.
|
||||
|
||||
## Implemented functionality (this pass, on top of the already-accepted M0–M7/design baseline)
|
||||
|
||||
- Fixed two confirmed defects: Vehicles and Bookings both computed a filtered/paginated
|
||||
result but rendered the raw array.
|
||||
- Server-backed session lifecycle (`GET /demo/session`, `POST /demo/logout`), central
|
||||
401 handling, no more `sessionStorage`-as-authority.
|
||||
- A role matrix enforced server-side (403 on every manager-only action for Rental
|
||||
Employee, not just a hidden button) and mirrored in the nav/route guards.
|
||||
- Authoritative, non-mutating return preview (`POST /bookings/{ref}/return-preview`)
|
||||
sharing its evaluation function with commit — fixed a real bug where the frontend's
|
||||
guessed preview text was wrong (damage → described as "maintenance", actual rule
|
||||
"blocked"; the no-contradiction case → described as "available", actual rule always
|
||||
"cleaning" first).
|
||||
- Audit API/UI now expose `before`/`after` (the columns existed but were never
|
||||
serialized) plus a resolved safe entity link.
|
||||
- Typed related-entity snapshots (booking_overlap's related refs are bookings, not
|
||||
vehicles — previously silently unresolved) and a bounded resolution flow for every
|
||||
one of the five data-quality rule types, plus an audited manual scan trigger and
|
||||
documented recurrence linking (`reopened_from`/`previous_decision`).
|
||||
- Role-aware backend search (`GET /api/v1/search`) replacing a blind client-side regex
|
||||
guesser, with a real results panel, keyboard navigation and debouncing.
|
||||
- A safe, confirmed demo-reset UI trigger (the endpoint already existed and was
|
||||
already gated).
|
||||
- Truthful aggregate n8n integration status (`GET /api/v1/integrations/status`) from
|
||||
outbox delivery counts, replacing a single-most-recent-event read; fixed
|
||||
`MCP_HUB_REGISTRATION_ENABLED` being declared in `.env.example` but never wired into
|
||||
`Settings`.
|
||||
- Bounded outbox delivery-lease recovery for a process crash between claim and outcome.
|
||||
- A second n8n workflow (scheduled quality scan), independent of RAGcore/MCP Hub.
|
||||
|
||||
## RAGcore integration status
|
||||
|
||||
Unchanged from the prior baseline and honestly reported throughout: the demo
|
||||
`KnowledgeProvider` (deterministic TF-IDF extractive retrieval over local procedure
|
||||
documents) satisfies the knowledge-assistant acceptance criteria and is fully verified.
|
||||
A `RAGcoreKnowledgeProvider` HTTP adapter is implemented and unit-tested (including its
|
||||
unavailable-degradation path) but was never exercised against a live RAGcore instance in
|
||||
this environment — no live RAGcore instance exists to test against.
|
||||
`KNOWLEDGE_PROVIDER=demo` on the Unraid deployment; no simulated live connection is ever
|
||||
shown.
|
||||
|
||||
## MCP Hub integration status
|
||||
|
||||
The four read-only provider endpoints are implemented, tested, and directly
|
||||
curl-verified with correct service-token auth enforcement and audit logging.
|
||||
`MCP_HUB_REGISTRATION_ENABLED` — previously declared in `.env.example` but silently
|
||||
dropped by `extra="ignore"` since it had no `Settings` field — is now actually wired in
|
||||
and honestly reported (`GET /api/v1/integrations/status`'s `mcp_hub.state`). It is
|
||||
`false` on the Unraid deployment (`state: "not_configured"`). No live Hub instance was
|
||||
reachable in this environment to verify an actual Hub round trip.
|
||||
|
||||
## n8n integration status
|
||||
|
||||
Fully implemented and live-verified. The original return-processing workflow: verified
|
||||
against both the local bundled instance and the shared Unraid instance, including a real
|
||||
degraded-mode drill in an earlier session (n8n stopped mid-flow → return still committed
|
||||
locally, event stayed `pending` with backoff, self-healed once n8n returned) and the
|
||||
manual-retry path. This pass adds:
|
||||
|
||||
- **Truthful status**: `GET /api/v1/integrations/status` derives n8n health from
|
||||
aggregate outbox counts (pending/delivering/succeeded/failed), not the single most
|
||||
recent event.
|
||||
- **Stale-delivery-lease recovery**: a claimed-but-never-resolved `delivering` row (the
|
||||
process crashing between claim and outcome) is now recoverable; unit-tested including
|
||||
a simulated crash, and confirmed a still-alive worker's unexpired lease is never
|
||||
touched.
|
||||
- **Second workflow**: `mobilityops-scheduled-quality-scan` (hourly + manual-test
|
||||
trigger, ships `"active": false"`), calling `POST
|
||||
/api/v1/integrations/n8n/scheduled-scan`. Live-verified two ways: (1) executed
|
||||
end-to-end via the Manual test trigger against the **local** n8n instance — full
|
||||
green execution in the n8n editor, confirmed by the resulting
|
||||
`data_quality_scan_run` audit event (`actor_type=service`); (2) published to the
|
||||
**shared Unraid n8n** via `deploy/unraid/setup-scheduled-scan.sh` and the resulting
|
||||
endpoint directly curl-verified against the live deployed API, also confirmed via the
|
||||
audit trail. The shared instance's own UI could not be browser-tested directly — it
|
||||
runs `N8N_SECURE_COOKIE=true` and refuses login over the plain-HTTP LAN URL used for
|
||||
automated testing here, which is correct, pre-existing shared-infrastructure
|
||||
behaviour and out of scope to change.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- RAGcore and ITWorx MCP Hub remain honestly not-live-connected — no live instance of
|
||||
either exists in this environment (environment limitation, not a code defect).
|
||||
- `npm audit`: one moderate esbuild/Vite dev-server-only advisory (fix requires a Vite
|
||||
major upgrade, deliberately deferred), and a
|
||||
react-router RSC-mode advisory that doesn't apply (the app never uses RSC/SSR mode) —
|
||||
both pre-existing, confirmed unchanged by this pass's clean `npm ci`.
|
||||
- Demo authentication remains the accepted HMAC-cookie PoC mechanism tied to two fixed
|
||||
seeded identities — not a production identity provider.
|
||||
- The scheduled quality-scan workflow's own n8n-engine execution was verified live
|
||||
against the local bundled n8n and, for the HTTP round trip specifically, against the
|
||||
shared Unraid n8n's resulting API call — not against a full n8n-engine execution *on
|
||||
the shared instance itself*, for the browser-access reason above.
|
||||
- `n8n_delivery_lease_seconds` (120s default) is a code-level tunable, not exposed in
|
||||
`.env.example`, consistent with the existing `n8n_dispatch_interval_seconds`/
|
||||
`n8n_max_attempts`/`n8n_http_timeout_seconds` tunables already handled that way.
|
||||
|
||||
## Clean deployment instructions
|
||||
|
||||
See `deploy/unraid/README.md` and `docs/17-runbook.md` for the full runbook. Redeploy
|
||||
the exact committed revision:
|
||||
|
||||
```bash
|
||||
COMMIT=<commit to deploy>
|
||||
git archive --format=tar.gz --output=/tmp/source.tar.gz "$COMMIT"
|
||||
scp -P 22 /tmp/source.tar.gz unraid:/mnt/user/appdata/mobilityops/.deploy/source.tar.gz
|
||||
ssh unraid "cd /mnt/user/appdata/mobilityops \
|
||||
&& tar -xzf .deploy/source.tar.gz \
|
||||
&& echo $COMMIT > .deploy/source-revision \
|
||||
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web \
|
||||
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api alembic current \
|
||||
&& curl -fsS http://127.0.0.1:1236/health"
|
||||
```
|
||||
|
||||
`.env` and both named volumes (`mobilityops-db`, `mobilityops-n8n`) are preserved by
|
||||
this flow; nothing outside the `mobilityops` Compose project is touched.
|
||||
|
||||
## Five-minute demonstration flow
|
||||
|
||||
1. Open `http://192.168.10.150:1236`, log in as **Operations Manager**.
|
||||
2. Dashboard: point out the persisted readiness metrics and the now-truthful n8n
|
||||
integration-status card (aggregate counts, not just the latest event).
|
||||
3. Global search (`Ctrl/Cmd+K`): type a vehicle, booking or issue reference; use arrow
|
||||
keys + Enter to navigate; show the no-results state for a nonsense query.
|
||||
4. Bookings: filter by status, page through results (max 25/page), confirm page 2
|
||||
differs from page 1.
|
||||
5. Open an active booking → capture a return with a below-canonical odometer reading →
|
||||
the review step shows the server's authoritative evaluation (odometer regression
|
||||
flagged, resulting status and reason) → confirm → result screen distinguishes local
|
||||
commit success from queued-not-yet-confirmed n8n delivery, links to the created
|
||||
data-quality issue.
|
||||
6. Data quality: run a manual scan; open the newly flagged (or an existing) issue for
|
||||
each rule type and show its dedicated bounded resolution flow (not raw JSON).
|
||||
7. Audit trail: filter by the correlation ID from the return above; show the
|
||||
human-readable before/after change summary, expand the raw-JSON `<details>`.
|
||||
8. Switch role to **Rental Employee**: show Data Quality/Integrations/Audit are absent
|
||||
from the nav, and that direct URL navigation to any of them shows the restricted
|
||||
message rather than partial data or a crash.
|
||||
9. Switch back to Operations Manager, trigger **Reset demo data** with confirmation,
|
||||
land back at login, log in again to confirm deterministic data was restored.
|
||||
@@ -1,15 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import AuditEventOut, CurrentUser
|
||||
|
||||
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
|
||||
|
||||
# Only entity types with a stable public reference and (optionally) a real frontend route
|
||||
# are resolved here. Types like "system", "knowledge" or "mcp_tool" carry no linkable
|
||||
# entity_id and are left as plain labels.
|
||||
_ENTITY_MODELS: dict[str, Any] = {
|
||||
"vehicle": Vehicle,
|
||||
"booking": Booking,
|
||||
"customer": Customer,
|
||||
"data_quality_issue": DataQualityIssue,
|
||||
}
|
||||
_ROUTE_TEMPLATES: dict[str, str] = {
|
||||
"vehicle": "/vehicles/{ref}",
|
||||
"booking": "/bookings/{ref}",
|
||||
"data_quality_issue": "/data-quality/{ref}",
|
||||
# No customer detail route exists in this proof of concept; still resolve the
|
||||
# reference for display, just without a link.
|
||||
}
|
||||
|
||||
|
||||
def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid.UUID, str]:
|
||||
ids_by_type: dict[str, set[uuid.UUID]] = {}
|
||||
for event in events:
|
||||
if event.entity_id is not None and event.entity_type in _ENTITY_MODELS:
|
||||
ids_by_type.setdefault(event.entity_type, set()).add(event.entity_id)
|
||||
|
||||
refs: dict[uuid.UUID, str] = {}
|
||||
for entity_type, ids in ids_by_type.items():
|
||||
model = _ENTITY_MODELS[entity_type]
|
||||
rows: Sequence[Any] = db.scalars(select(model).where(model.id.in_(ids))).all()
|
||||
for row in rows:
|
||||
refs[row.id] = row.public_ref
|
||||
return refs
|
||||
|
||||
|
||||
@router.get("", response_model=list[AuditEventOut])
|
||||
def list_audit_events(
|
||||
@@ -19,7 +59,7 @@ def list_audit_events(
|
||||
correlation_id: str | None = Query(default=None),
|
||||
limit: int = Query(default=100, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> list[AuditEventOut]:
|
||||
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit)
|
||||
if actor_label:
|
||||
@@ -31,7 +71,13 @@ def list_audit_events(
|
||||
if correlation_id:
|
||||
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
|
||||
events = db.scalars(stmt).all()
|
||||
return [
|
||||
entity_refs = _resolve_entity_refs(db, events)
|
||||
|
||||
out = []
|
||||
for e in events:
|
||||
ref = entity_refs.get(e.entity_id) if e.entity_id else None
|
||||
route = _ROUTE_TEMPLATES.get(e.entity_type)
|
||||
out.append(
|
||||
AuditEventOut(
|
||||
id=str(e.id),
|
||||
actor_type=e.actor_type,
|
||||
@@ -39,9 +85,13 @@ def list_audit_events(
|
||||
action=e.action,
|
||||
entity_type=e.entity_type,
|
||||
entity_id=str(e.entity_id) if e.entity_id else None,
|
||||
entity_ref=ref,
|
||||
entity_link=route.format(ref=ref) if route and ref else None,
|
||||
correlation_id=str(e.correlation_id),
|
||||
occurred_at=e.occurred_at,
|
||||
before=e.before_json,
|
||||
after=e.after_json,
|
||||
metadata=e.metadata_json,
|
||||
)
|
||||
for e in events
|
||||
]
|
||||
)
|
||||
return out
|
||||
|
||||
@@ -8,8 +8,14 @@ from app.api.deps import get_current_user, get_db
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import BookingOut, CurrentUser, RegisterReturnRequest
|
||||
from app.services.returns import register_vehicle_return
|
||||
from app.schemas import (
|
||||
BookingOut,
|
||||
CurrentUser,
|
||||
NextBookingRisk,
|
||||
RegisterReturnRequest,
|
||||
ReturnPreviewResult,
|
||||
)
|
||||
from app.services.returns import preview_vehicle_return, register_vehicle_return
|
||||
|
||||
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
|
||||
|
||||
@@ -66,6 +72,33 @@ def get_booking(
|
||||
return _to_out(booking, customer, vehicle)
|
||||
|
||||
|
||||
@router.post("/{public_ref}/return-preview", response_model=ReturnPreviewResult)
|
||||
def preview_return(
|
||||
public_ref: str,
|
||||
body: RegisterReturnRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ReturnPreviewResult:
|
||||
booking, vehicle, evaluation = preview_vehicle_return(db, public_ref, body)
|
||||
return ReturnPreviewResult(
|
||||
booking_ref=booking.public_ref,
|
||||
vehicle_ref=vehicle.public_ref,
|
||||
canonical_odometer_km=evaluation.canonical_odometer_km,
|
||||
submitted_odometer_km=evaluation.submitted_odometer_km,
|
||||
odometer_regression=evaluation.odometer_regression,
|
||||
resulting_odometer_km=evaluation.resulting_odometer_km,
|
||||
resulting_vehicle_status=evaluation.resulting_vehicle_status,
|
||||
status_reason=evaluation.status_reason,
|
||||
would_create_quality_issue=evaluation.would_create_quality_issue,
|
||||
attention_reasons=evaluation.attention_reasons,
|
||||
next_booking_risk=(
|
||||
NextBookingRisk(**evaluation.next_booking_risk)
|
||||
if evaluation.next_booking_risk is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_ref}/return")
|
||||
def register_return(
|
||||
public_ref: str,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -30,7 +30,9 @@ _SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
||||
|
||||
|
||||
def _today() -> date:
|
||||
return datetime.fromisoformat(settings.demo_today).date()
|
||||
# Seeded dates are shifted to the real reset moment by `seed_loader.py`'s anchor
|
||||
# shift, so "today" must be real wall-clock time, not the frozen `demo_today` setting.
|
||||
return datetime.now(UTC).date()
|
||||
|
||||
|
||||
@router.get("", response_model=DashboardOut)
|
||||
@@ -59,12 +61,11 @@ def get_dashboard(
|
||||
entity = customers_by_id.get(issue.entity_id)
|
||||
link_type = "customer"
|
||||
link_ref = entity.public_ref if entity else ""
|
||||
title = f"{issue.rule_type.replace('_', ' ').title()} — {link_ref}"
|
||||
attention_items.append(
|
||||
AttentionItem(
|
||||
kind="quality_issue",
|
||||
severity=issue.severity,
|
||||
title=title,
|
||||
rule_type=issue.rule_type,
|
||||
detail=issue.evidence_json.get("summary", ""),
|
||||
link_type=link_type,
|
||||
link_ref=link_ref,
|
||||
|
||||
@@ -4,19 +4,34 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db, require_operations_manager
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import (
|
||||
ApplyRecommendedStatusResult,
|
||||
CurrentUser,
|
||||
DataQualityIssueDetailOut,
|
||||
DataQualityIssueOut,
|
||||
MergeCustomersRequest,
|
||||
MergeCustomersResult,
|
||||
ProvideFieldsRequest,
|
||||
ResolveOdometerRegressionRequest,
|
||||
ResolveOverlapRequest,
|
||||
ScanResultOut,
|
||||
)
|
||||
from app.services.data_quality import defer_issue, merge_customers, reject_issue, run_scan
|
||||
from app.services.data_quality import (
|
||||
apply_recommended_status,
|
||||
defer_issue,
|
||||
merge_customers,
|
||||
provide_missing_fields,
|
||||
reject_issue,
|
||||
resolve_booking_overlap,
|
||||
resolve_odometer_regression,
|
||||
run_scan,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/data-quality", tags=["data-quality"])
|
||||
|
||||
@@ -41,7 +56,7 @@ def list_issues(
|
||||
rule_type: str | None = Query(default=None),
|
||||
severity: str | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> list[DataQualityIssueOut]:
|
||||
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
|
||||
if status:
|
||||
@@ -54,12 +69,33 @@ def list_issues(
|
||||
return [_to_out(i) for i in issues]
|
||||
|
||||
|
||||
# Every public reference in this system carries its entity type in its own prefix
|
||||
# (CUS-/MO-/BK-/INSP-/DQ-). Related-entity typing is resolved from the reference itself,
|
||||
# not guessed from the issue's rule_type -- a booking_overlap issue's related refs are
|
||||
# bookings, not vehicles, and an inline odometer_regression issue's related refs mix a
|
||||
# booking and an inspection ref in the same list.
|
||||
_PREFIX_TO_TYPE = {
|
||||
"CUS-": "customer",
|
||||
"MO-": "vehicle",
|
||||
"BK-": "booking",
|
||||
"INSP-": "inspection",
|
||||
}
|
||||
|
||||
|
||||
def _entity_type_for_ref(ref: str) -> str | None:
|
||||
for prefix, entity_type in _PREFIX_TO_TYPE.items():
|
||||
if ref.startswith(prefix):
|
||||
return entity_type
|
||||
return None
|
||||
|
||||
|
||||
def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
||||
if entity_type == "customer":
|
||||
customer = db.scalar(select(Customer).where(Customer.public_ref == ref))
|
||||
if customer is None:
|
||||
return None
|
||||
return {
|
||||
"entity_type": "customer",
|
||||
"public_ref": customer.public_ref,
|
||||
"first_name": customer.first_name,
|
||||
"last_name": customer.last_name,
|
||||
@@ -68,10 +104,12 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
||||
"postal_code": customer.postal_code,
|
||||
"city": customer.city,
|
||||
}
|
||||
if entity_type == "vehicle":
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == ref))
|
||||
if vehicle is None:
|
||||
return None
|
||||
return {
|
||||
"entity_type": "vehicle",
|
||||
"public_ref": vehicle.public_ref,
|
||||
"make": vehicle.make,
|
||||
"model": vehicle.model,
|
||||
@@ -79,30 +117,61 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
||||
"operational_status": vehicle.operational_status,
|
||||
"odometer_km": vehicle.odometer_km,
|
||||
}
|
||||
if entity_type == "booking":
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == ref))
|
||||
if booking is None:
|
||||
return None
|
||||
vehicle = db.get(Vehicle, booking.vehicle_id)
|
||||
customer = db.get(Customer, booking.customer_id)
|
||||
return {
|
||||
"entity_type": "booking",
|
||||
"public_ref": booking.public_ref,
|
||||
"status": booking.status,
|
||||
"starts_at": booking.starts_at.isoformat(),
|
||||
"ends_at": booking.ends_at.isoformat(),
|
||||
"vehicle_ref": vehicle.public_ref if vehicle else None,
|
||||
"customer_ref": customer.public_ref if customer else None,
|
||||
"end_odometer_km": booking.end_odometer_km,
|
||||
}
|
||||
if entity_type == "inspection":
|
||||
inspection = db.scalar(select(Inspection).where(Inspection.public_ref == ref))
|
||||
if inspection is None:
|
||||
return None
|
||||
booking = db.get(Booking, inspection.booking_id)
|
||||
return {
|
||||
"entity_type": "inspection",
|
||||
"public_ref": inspection.public_ref,
|
||||
"type": inspection.type,
|
||||
"odometer_km": inspection.odometer_km,
|
||||
"completed_at": inspection.completed_at.isoformat(),
|
||||
"booking_ref": booking.public_ref if booking else None,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/issues/{public_ref}", response_model=DataQualityIssueDetailOut)
|
||||
def get_issue(
|
||||
public_ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> DataQualityIssueDetailOut:
|
||||
issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref))
|
||||
if issue is None:
|
||||
raise HTTPException(status_code=404, detail="Data quality issue not found")
|
||||
base = _to_out(issue)
|
||||
related_refs = issue.evidence_json.get("related_refs", [])
|
||||
related_entity_type = (
|
||||
"customer" if issue.rule_type == "possible_duplicate_customer" else "vehicle"
|
||||
)
|
||||
related_snapshots = []
|
||||
for ref in related_refs:
|
||||
entity_type = _entity_type_for_ref(ref)
|
||||
if entity_type is None:
|
||||
continue
|
||||
snap = _snapshot(entity_type, ref, db)
|
||||
if snap is not None:
|
||||
related_snapshots.append(snap)
|
||||
return DataQualityIssueDetailOut(
|
||||
**base.model_dump(),
|
||||
entity_snapshot=_snapshot(issue.entity_type, base.entity_ref, db),
|
||||
related_snapshots=[
|
||||
snap
|
||||
for ref in related_refs
|
||||
if (snap := _snapshot(related_entity_type, ref, db)) is not None
|
||||
],
|
||||
related_snapshots=related_snapshots,
|
||||
)
|
||||
|
||||
|
||||
@@ -110,7 +179,7 @@ def get_issue(
|
||||
def defer(
|
||||
public_ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> DataQualityIssueOut:
|
||||
issue = defer_issue(db, public_ref, user)
|
||||
return _to_out(issue)
|
||||
@@ -120,7 +189,7 @@ def defer(
|
||||
def reject(
|
||||
public_ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> DataQualityIssueOut:
|
||||
issue = reject_issue(db, public_ref, user)
|
||||
return _to_out(issue)
|
||||
@@ -137,10 +206,59 @@ def merge(
|
||||
return MergeCustomersResult(**result)
|
||||
|
||||
|
||||
@router.post("/issues/{public_ref}/provide-fields", response_model=DataQualityIssueOut)
|
||||
def provide_fields(
|
||||
public_ref: str,
|
||||
body: ProvideFieldsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> DataQualityIssueOut:
|
||||
issue = provide_missing_fields(db, public_ref, body.fields, user)
|
||||
return _to_out(issue)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut
|
||||
)
|
||||
def resolve_odometer(
|
||||
public_ref: str,
|
||||
body: ResolveOdometerRegressionRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> DataQualityIssueOut:
|
||||
issue = resolve_odometer_regression(db, public_ref, body, user)
|
||||
return _to_out(issue)
|
||||
|
||||
|
||||
@router.post("/issues/{public_ref}/resolve-overlap", response_model=DataQualityIssueOut)
|
||||
def resolve_overlap(
|
||||
public_ref: str,
|
||||
body: ResolveOverlapRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> DataQualityIssueOut:
|
||||
issue = resolve_booking_overlap(db, public_ref, body.booking_ref, body.note, user)
|
||||
return _to_out(issue)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/issues/{public_ref}/apply-recommended-status", response_model=ApplyRecommendedStatusResult
|
||||
)
|
||||
def apply_status(
|
||||
public_ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> ApplyRecommendedStatusResult:
|
||||
issue, applied_status, reason = apply_recommended_status(db, public_ref, user)
|
||||
return ApplyRecommendedStatusResult(
|
||||
issue=_to_out(issue), applied_status=applied_status, reason=reason
|
||||
)
|
||||
|
||||
|
||||
@router.post("/scan", response_model=ScanResultOut)
|
||||
def scan(
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> ScanResultOut:
|
||||
result = run_scan(db)
|
||||
result = run_scan(db, actor_label=user.display_name, actor_type="user")
|
||||
return ScanResultOut(created=result.created)
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.api.deps import get_current_user, get_db, require_operations_manager
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import SessionPayload, create_session_token
|
||||
from app.core.security import SessionPayload, create_session_token, read_session_token
|
||||
from app.models.user import User
|
||||
from app.schemas import CurrentUser, DemoLoginRequest
|
||||
from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
|
||||
from app.seed_loader import reset_and_seed
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report
|
||||
|
||||
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@router.get("/manifest", response_model=DemoManifestOut)
|
||||
def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut:
|
||||
# Deliberately unauthenticated: the demo-entry screen and the permanent demo badge
|
||||
# both need this before any session exists. Nothing here is sensitive — it's the same
|
||||
# honest "what is this demo" summary a logged-in user would see.
|
||||
return build_demo_manifest(db)
|
||||
|
||||
|
||||
@router.post("/login", response_model=CurrentUser)
|
||||
def demo_login(
|
||||
body: DemoLoginRequest, response: Response, db: Session = Depends(get_db)
|
||||
@@ -41,6 +51,7 @@ def demo_login(
|
||||
token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=settings.session_cookie_secure,
|
||||
max_age=settings.session_ttl_seconds,
|
||||
)
|
||||
record_audit_event(
|
||||
@@ -56,21 +67,65 @@ def demo_login(
|
||||
return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=body.role)
|
||||
|
||||
|
||||
@router.get("/session", response_model=CurrentUser)
|
||||
def get_session(
|
||||
response: Response, user: CurrentUser = Depends(get_current_user)
|
||||
) -> CurrentUser:
|
||||
# Never let the browser (or an intermediary) cache an authentication check — a stale
|
||||
# cached 200 here would keep showing a logged-out browser as authenticated.
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
payload = read_session_token(token) if token else None
|
||||
if payload is not None:
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_id=uuid.UUID(payload.user_id),
|
||||
actor_label=payload.display_name,
|
||||
action="demo_logout",
|
||||
entity_type="user",
|
||||
)
|
||||
db.commit()
|
||||
response.delete_cookie(settings.session_cookie_name)
|
||||
return {"status": "logged_out"}
|
||||
|
||||
|
||||
@router.post("/reset")
|
||||
def demo_reset(
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> dict:
|
||||
if not settings.demo_allow_reset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Demo reset is disabled on this deployment.",
|
||||
)
|
||||
result = reset_and_seed(db)
|
||||
integrity = scenario_integrity_report(db)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="demo_reset",
|
||||
entity_type="system",
|
||||
metadata={"counts": result.counts},
|
||||
metadata={
|
||||
"counts": result.counts,
|
||||
"anchor_date": result.anchor_date.isoformat(),
|
||||
"scenario_integrity": integrity,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
response.delete_cookie(settings.session_cookie_name)
|
||||
return {"status": "reset", "counts": result.counts}
|
||||
return {
|
||||
"status": "reset",
|
||||
"counts": result.counts,
|
||||
"anchor_date": result.anchor_date.isoformat(),
|
||||
"seeded_at": result.seeded_at.isoformat(),
|
||||
"scenario_integrity": integrity,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.core.config import get_settings
|
||||
from app.schemas import (
|
||||
CurrentUser,
|
||||
IntegrationStatusOut,
|
||||
McpHubIntegrationStatus,
|
||||
)
|
||||
from app.services.integration_status import derive_n8n_status
|
||||
|
||||
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@router.get("/status", response_model=IntegrationStatusOut)
|
||||
def integration_status(
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> IntegrationStatusOut:
|
||||
return IntegrationStatusOut(
|
||||
n8n=derive_n8n_status(db),
|
||||
mcp_hub=McpHubIntegrationStatus(
|
||||
registration_enabled=settings.mcp_hub_registration_enabled,
|
||||
state="configured" if settings.mcp_hub_registration_enabled else "not_configured",
|
||||
),
|
||||
)
|
||||
@@ -13,7 +13,9 @@ from app.core.config import get_settings
|
||||
from app.core.errors import AppError
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.schemas import ScanResultOut
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.data_quality import run_scan
|
||||
|
||||
router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"])
|
||||
settings = get_settings()
|
||||
@@ -71,3 +73,19 @@ def return_callback(
|
||||
"event_id": str(event_id),
|
||||
"occurred_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/scheduled-scan", response_model=ScanResultOut)
|
||||
def scheduled_scan(
|
||||
service_token: str = Header(..., alias="X-Service-Token"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ScanResultOut:
|
||||
"""Triggered by the scheduled n8n quality-scan workflow. Narrow, read-mostly, and
|
||||
safe to call repeatedly: run_scan() only ever creates an issue for a condition that
|
||||
doesn't already have one open, so a duplicate or overlapping trigger does no
|
||||
duplicate domain work -- it just reports zero new issues for anything already known."""
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
|
||||
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
||||
return ScanResultOut(created=result.created)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -13,9 +14,12 @@ from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledg
|
||||
|
||||
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
|
||||
|
||||
SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"]
|
||||
|
||||
|
||||
class AskQuestionRequest(BaseModel):
|
||||
question: str = Field(min_length=3, max_length=1000)
|
||||
language: SupportedLanguage = "en-GB"
|
||||
|
||||
|
||||
@router.post("/questions", response_model=GroundedAnswer)
|
||||
@@ -26,7 +30,7 @@ def ask_question(
|
||||
) -> GroundedAnswer:
|
||||
correlation_id = str(uuid.uuid4())
|
||||
provider = get_knowledge_provider()
|
||||
answer = provider.ask(body.question, correlation_id)
|
||||
answer = provider.ask(body.question, correlation_id, body.language)
|
||||
|
||||
record_audit_event(
|
||||
db,
|
||||
@@ -40,6 +44,7 @@ def ask_question(
|
||||
"provider": answer.provider,
|
||||
"source_ids": [s.document_id for s in answer.sources],
|
||||
"question_length": len(body.question),
|
||||
"language": body.language,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
@@ -47,5 +52,8 @@ def ask_question(
|
||||
|
||||
|
||||
@router.get("/status", response_model=KnowledgeHealth)
|
||||
def knowledge_status(_user: CurrentUser = Depends(get_current_user)) -> KnowledgeHealth:
|
||||
return get_knowledge_provider().health()
|
||||
def knowledge_status(
|
||||
language: SupportedLanguage = "en-GB",
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> KnowledgeHealth:
|
||||
return get_knowledge_provider().health(language)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.models.booking import Booking
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import CurrentUser, SearchResponse, SearchResultItem
|
||||
|
||||
router = APIRouter(prefix="/api/v1/search", tags=["search"])
|
||||
|
||||
# Static application sections. Manager-only sections are filtered by role, mirroring the
|
||||
# same nav visibility rule Layout.tsx applies -- search must never surface a destination
|
||||
# the current role can't actually reach.
|
||||
_SECTIONS: list[dict] = [
|
||||
{
|
||||
"label": "Overview",
|
||||
"detail": "Operations dashboard",
|
||||
"link": "/dashboard",
|
||||
"terms": ["overview", "dashboard", "readiness"],
|
||||
},
|
||||
{
|
||||
"label": "Fleet",
|
||||
"detail": "Vehicle registry",
|
||||
"link": "/vehicles",
|
||||
"terms": ["fleet", "vehicle", "vehicles"],
|
||||
},
|
||||
{
|
||||
"label": "Bookings",
|
||||
"detail": "Rental bookings",
|
||||
"link": "/bookings",
|
||||
"terms": ["booking", "bookings", "rental"],
|
||||
},
|
||||
{
|
||||
"label": "Data quality",
|
||||
"detail": "Quality workbench",
|
||||
"link": "/data-quality",
|
||||
"terms": ["quality", "data quality", "issues"],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
{
|
||||
"label": "Knowledge",
|
||||
"detail": "Procedure assistant",
|
||||
"link": "/knowledge",
|
||||
"terms": ["knowledge", "procedures"],
|
||||
},
|
||||
{
|
||||
"label": "Integrations",
|
||||
"detail": "Automation and integration status",
|
||||
"link": "/automation",
|
||||
"terms": ["automation", "integrations", "systems", "n8n"],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
{
|
||||
"label": "Audit trail",
|
||||
"detail": "Audit history",
|
||||
"link": "/audit",
|
||||
"terms": ["audit", "history"],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=SearchResponse)
|
||||
def search(
|
||||
q: str = Query(min_length=1, max_length=100),
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> SearchResponse:
|
||||
query = q.strip()
|
||||
normalized = query.lower()
|
||||
results: list[SearchResultItem] = []
|
||||
|
||||
for section in _SECTIONS:
|
||||
role = section.get("role")
|
||||
if role and user.role != role:
|
||||
continue
|
||||
terms: list[str] = section["terms"]
|
||||
if any(term in normalized or normalized in term for term in terms):
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
type="section",
|
||||
label=section["label"],
|
||||
detail=section["detail"],
|
||||
link=section["link"],
|
||||
)
|
||||
)
|
||||
|
||||
like = f"%{query}%"
|
||||
for v in db.scalars(
|
||||
select(Vehicle)
|
||||
.where(
|
||||
or_(
|
||||
Vehicle.public_ref.ilike(like),
|
||||
Vehicle.make.ilike(like),
|
||||
Vehicle.model.ilike(like),
|
||||
Vehicle.registration_number.ilike(like),
|
||||
Vehicle.location.ilike(like),
|
||||
)
|
||||
)
|
||||
.order_by(Vehicle.public_ref)
|
||||
.limit(5)
|
||||
).all():
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
type="vehicle",
|
||||
label=v.public_ref,
|
||||
detail=f"{v.make} {v.model} · {v.location}",
|
||||
link=f"/vehicles/{v.public_ref}",
|
||||
)
|
||||
)
|
||||
|
||||
for b in db.scalars(
|
||||
select(Booking).where(Booking.public_ref.ilike(like)).order_by(Booking.starts_at.desc()).limit(5)
|
||||
).all():
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
type="booking",
|
||||
label=b.public_ref,
|
||||
detail=b.status,
|
||||
link=f"/bookings/{b.public_ref}",
|
||||
)
|
||||
)
|
||||
|
||||
# No customer detail route exists in this proof of concept, so customers are
|
||||
# deliberately never returned here -- there is nowhere useful to send the user.
|
||||
if user.role == "operations_manager":
|
||||
for i in db.scalars(
|
||||
select(DataQualityIssue)
|
||||
.where(DataQualityIssue.public_ref.ilike(like))
|
||||
.order_by(DataQualityIssue.detected_at.desc())
|
||||
.limit(5)
|
||||
).all():
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
type="data_quality_issue",
|
||||
label=i.public_ref,
|
||||
detail=i.rule_type.replace("_", " "),
|
||||
link=f"/data-quality/{i.public_ref}",
|
||||
)
|
||||
)
|
||||
|
||||
return SearchResponse(query=query, results=results[:10])
|
||||
@@ -22,14 +22,19 @@ class Settings(BaseSettings):
|
||||
n8n_dispatch_interval_seconds: float = 3.0
|
||||
n8n_http_timeout_seconds: float = 5.0
|
||||
n8n_max_attempts: int = 5
|
||||
n8n_delivery_lease_seconds: float = 120.0
|
||||
app_secret: str = "replace-in-production"
|
||||
session_cookie_name: str = "mobilityops_session"
|
||||
session_ttl_seconds: int = 60 * 60 * 8
|
||||
session_cookie_secure: bool = False
|
||||
seed_dir: str = "/app/seed"
|
||||
knowledge_dir: str = "/app/knowledge/procedures"
|
||||
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
||||
mcp_hub_registration_enabled: bool = False
|
||||
cors_allow_origins: str = "http://localhost:1228"
|
||||
demo_today: str = "2026-08-01"
|
||||
demo_organization_name: str = "Northstar Mobility"
|
||||
demo_timezone: str = "Europe/Brussels"
|
||||
demo_allow_reset: bool = True
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -11,9 +11,11 @@ from app.api.routers import (
|
||||
dashboard,
|
||||
data_quality,
|
||||
demo,
|
||||
integration_status,
|
||||
integrations,
|
||||
knowledge,
|
||||
mcp_integrations,
|
||||
search,
|
||||
vehicles,
|
||||
workflows,
|
||||
)
|
||||
@@ -88,3 +90,5 @@ app.include_router(workflows.router)
|
||||
app.include_router(integrations.router)
|
||||
app.include_router(knowledge.router)
|
||||
app.include_router(mcp_integrations.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(integration_status.router)
|
||||
|
||||
@@ -74,6 +74,20 @@ class RegisterReturnResult(BaseModel):
|
||||
next_booking_risk: NextBookingRisk | None
|
||||
|
||||
|
||||
class ReturnPreviewResult(BaseModel):
|
||||
booking_ref: str
|
||||
vehicle_ref: str
|
||||
canonical_odometer_km: int
|
||||
submitted_odometer_km: int
|
||||
odometer_regression: bool
|
||||
resulting_odometer_km: int
|
||||
resulting_vehicle_status: str
|
||||
status_reason: str
|
||||
would_create_quality_issue: bool
|
||||
attention_reasons: list[str]
|
||||
next_booking_risk: NextBookingRisk | None
|
||||
|
||||
|
||||
class InspectionOut(BaseModel):
|
||||
public_ref: str
|
||||
booking_ref: str
|
||||
@@ -127,6 +141,93 @@ class ScanResultOut(BaseModel):
|
||||
created: dict[str, int]
|
||||
|
||||
|
||||
class ProvideFieldsRequest(BaseModel):
|
||||
fields: dict[str, str]
|
||||
|
||||
|
||||
class ResolveOdometerRegressionRequest(BaseModel):
|
||||
decision: Literal["retain_canonical", "correct_reading"]
|
||||
booking_ref: str | None = None
|
||||
corrected_odometer_km: Annotated[int, Field(ge=0)] | None = None
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class ResolveOverlapRequest(BaseModel):
|
||||
booking_ref: str
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class ApplyRecommendedStatusResult(BaseModel):
|
||||
issue: DataQualityIssueOut
|
||||
applied_status: str
|
||||
reason: str
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
type: Literal["vehicle", "booking", "data_quality_issue", "section"]
|
||||
label: str
|
||||
detail: str
|
||||
link: str
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
query: str
|
||||
results: list[SearchResultItem]
|
||||
|
||||
|
||||
class N8nIntegrationStatus(BaseModel):
|
||||
configured: bool
|
||||
dispatch_enabled: bool
|
||||
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||
pending: int
|
||||
delivering: int
|
||||
failed: int
|
||||
succeeded: int
|
||||
latest_success_at: datetime | None
|
||||
latest_failure_at: datetime | None
|
||||
|
||||
|
||||
class McpHubIntegrationStatus(BaseModel):
|
||||
registration_enabled: bool
|
||||
state: Literal["not_configured", "configured"]
|
||||
|
||||
|
||||
class IntegrationStatusOut(BaseModel):
|
||||
n8n: N8nIntegrationStatus
|
||||
mcp_hub: McpHubIntegrationStatus
|
||||
|
||||
|
||||
class DemoScenarioOut(BaseModel):
|
||||
id: str
|
||||
estimated_minutes: int
|
||||
required_roles: list[Role]
|
||||
start_path: str
|
||||
ready: bool
|
||||
blocked_reason_code: str | None = None
|
||||
blocked_reason_params: dict[str, str] = {}
|
||||
|
||||
|
||||
class DemoIntegrationSummaryOut(BaseModel):
|
||||
key: Literal["n8n", "ragcore", "mcp_hub"]
|
||||
status_code: str
|
||||
detail_code: str
|
||||
detail_params: dict[str, str | int] = {}
|
||||
|
||||
|
||||
class DemoManifestOut(BaseModel):
|
||||
demo_mode: bool
|
||||
organization_name: str
|
||||
timezone: str
|
||||
synthetic_data: bool
|
||||
allow_reset: bool
|
||||
last_reset_at: datetime | None
|
||||
anchor_date: str | None
|
||||
guide_available: bool
|
||||
required_roles: list[Role]
|
||||
scenarios: list[DemoScenarioOut]
|
||||
integrations: list[DemoIntegrationSummaryOut]
|
||||
|
||||
|
||||
class VehicleDetailOut(VehicleOut):
|
||||
bookings: list[BookingSummaryOut] = Field(default_factory=list)
|
||||
inspections: list[InspectionOut] = Field(default_factory=list)
|
||||
@@ -147,7 +248,7 @@ class DashboardMetrics(BaseModel):
|
||||
class AttentionItem(BaseModel):
|
||||
kind: Literal["quality_issue", "vehicle"]
|
||||
severity: str
|
||||
title: str
|
||||
rule_type: str
|
||||
detail: str
|
||||
link_type: Literal["vehicle", "booking", "customer"]
|
||||
link_ref: str
|
||||
@@ -216,6 +317,10 @@ class AuditEventOut(BaseModel):
|
||||
action: str
|
||||
entity_type: str
|
||||
entity_id: str | None
|
||||
entity_ref: str | None = None
|
||||
entity_link: str | None = None
|
||||
correlation_id: str
|
||||
occurred_at: datetime
|
||||
before: dict[str, Any] | None = None
|
||||
after: dict[str, Any] | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import csv
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import delete, insert, update
|
||||
@@ -20,6 +20,7 @@ from app.models.maintenance import MaintenanceRecord
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.models.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@@ -36,6 +37,17 @@ DEMO_USERS = [
|
||||
},
|
||||
]
|
||||
|
||||
# seed/generate_seed.py authored the committed CSVs relative to this fixed date
|
||||
# (`--anchor 2026-08-01`, matching Settings.demo_today). Every reset shifts every
|
||||
# seeded date by (today - SEED_AUTHORED_ANCHOR) so "today" / "near-future" / "overlaps
|
||||
# right now" scenarios stay true to the actual reset moment instead of decaying as real
|
||||
# time passes between resets -- a fixed anchor with no shift goes stale within days.
|
||||
SEED_AUTHORED_ANCHOR = date(2026, 8, 1)
|
||||
|
||||
|
||||
def _seed_anchor_shift(today: date) -> timedelta:
|
||||
return today - SEED_AUTHORED_ANCHOR
|
||||
|
||||
|
||||
def _parse_dt(value: str) -> datetime:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
@@ -53,6 +65,8 @@ def _parse_optional_int(value: str) -> int | None:
|
||||
@dataclass
|
||||
class SeedResult:
|
||||
counts: dict[str, int]
|
||||
anchor_date: date
|
||||
seeded_at: datetime
|
||||
|
||||
|
||||
def _seed_dir() -> Path:
|
||||
@@ -83,6 +97,8 @@ def clear_all(db: Session) -> None:
|
||||
|
||||
def load_seed(db: Session) -> SeedResult:
|
||||
counts: dict[str, int] = {}
|
||||
today = datetime.now(UTC).date()
|
||||
shift = _seed_anchor_shift(today)
|
||||
|
||||
user_rows = [
|
||||
{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS
|
||||
@@ -154,8 +170,8 @@ def load_seed(db: Session) -> SeedResult:
|
||||
"public_ref": row["public_ref"],
|
||||
"customer_id": customer_id_by_ref[row["customer_ref"]],
|
||||
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
||||
"starts_at": _parse_dt(row["starts_at"]),
|
||||
"ends_at": _parse_dt(row["ends_at"]),
|
||||
"starts_at": _parse_dt(row["starts_at"]) + shift,
|
||||
"ends_at": _parse_dt(row["ends_at"]) + shift,
|
||||
"status": row["status"],
|
||||
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
|
||||
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
|
||||
@@ -179,7 +195,7 @@ def load_seed(db: Session) -> SeedResult:
|
||||
"damage_reported": _parse_bool(row["damage_reported"]),
|
||||
"technical_warning": _parse_bool(row["technical_warning"]),
|
||||
"odometer_km": int(row["odometer_km"]),
|
||||
"completed_at": _parse_dt(row["completed_at"]),
|
||||
"completed_at": _parse_dt(row["completed_at"]) + shift,
|
||||
"completed_by": None,
|
||||
}
|
||||
)
|
||||
@@ -193,7 +209,7 @@ def load_seed(db: Session) -> SeedResult:
|
||||
"id": uuid.uuid4(),
|
||||
"public_ref": row["public_ref"],
|
||||
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
||||
"occurred_at": _parse_dt(row["occurred_at"]),
|
||||
"occurred_at": _parse_dt(row["occurred_at"]) + shift,
|
||||
"odometer_km": int(row["odometer_km"]),
|
||||
"category": row["category"],
|
||||
"summary": row["summary"],
|
||||
@@ -266,7 +282,7 @@ def load_seed(db: Session) -> SeedResult:
|
||||
},
|
||||
"aggregate_ref": row["aggregate_ref"],
|
||||
},
|
||||
"occurred_at": _parse_dt(row["occurred_at"]),
|
||||
"occurred_at": _parse_dt(row["occurred_at"]) + shift,
|
||||
"delivery_status": row["status"],
|
||||
"attempts": int(row["attempts"]),
|
||||
"next_attempt_at": None,
|
||||
@@ -277,7 +293,21 @@ def load_seed(db: Session) -> SeedResult:
|
||||
db.execute(insert(OutboxEvent), outbox_rows)
|
||||
counts["workflow_runs"] = len(outbox_rows)
|
||||
|
||||
return SeedResult(counts=counts)
|
||||
seeded_at = datetime.now(UTC)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="system",
|
||||
actor_label="seed loader",
|
||||
action="demo_data_seeded",
|
||||
entity_type="system",
|
||||
metadata={
|
||||
"anchor_date": today.isoformat(),
|
||||
"seed_authored_anchor": SEED_AUTHORED_ANCHOR.isoformat(),
|
||||
"counts": counts,
|
||||
},
|
||||
)
|
||||
|
||||
return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at)
|
||||
|
||||
|
||||
def reset_and_seed(db: Session) -> SeedResult:
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import CurrentUser
|
||||
from app.schemas import CurrentUser, ResolveOdometerRegressionRequest
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name")
|
||||
@@ -73,6 +73,29 @@ def _open_issue(
|
||||
if _has_open_issue(db, rule_type, entity_type, entity_id):
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Reintroduced evidence creates a new issue rather than silently reopening the old
|
||||
# one, but it stays linked to whatever decision was made last time so an operator
|
||||
# doesn't re-litigate from a blank slate.
|
||||
previous = db.scalar(
|
||||
select(DataQualityIssue)
|
||||
.where(
|
||||
DataQualityIssue.rule_type == rule_type,
|
||||
DataQualityIssue.entity_type == entity_type,
|
||||
DataQualityIssue.entity_id == entity_id,
|
||||
DataQualityIssue.status != "open",
|
||||
)
|
||||
.order_by(DataQualityIssue.detected_at.desc())
|
||||
)
|
||||
evidence: dict = {
|
||||
"summary": summary,
|
||||
"entity_ref": entity_ref,
|
||||
"related_refs": related_refs,
|
||||
}
|
||||
if previous is not None:
|
||||
evidence["reopened_from"] = previous.public_ref
|
||||
evidence["previous_decision"] = previous.status
|
||||
|
||||
issue = DataQualityIssue(
|
||||
public_ref=_next_public_ref(db, "DQ-SCAN"),
|
||||
rule_type=rule_type,
|
||||
@@ -80,11 +103,7 @@ def _open_issue(
|
||||
entity_id=entity_id,
|
||||
severity=severity,
|
||||
status="open",
|
||||
evidence_json={
|
||||
"summary": summary,
|
||||
"entity_ref": entity_ref,
|
||||
"related_refs": related_refs,
|
||||
},
|
||||
evidence_json=evidence,
|
||||
proposed_action_json={},
|
||||
detected_at=now,
|
||||
)
|
||||
@@ -280,13 +299,24 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
|
||||
break
|
||||
|
||||
|
||||
def run_scan(db: Session) -> ScanResult:
|
||||
def run_scan(
|
||||
db: Session, *, actor_label: str | None = None, actor_type: str = "user"
|
||||
) -> ScanResult:
|
||||
scan = ScanResult()
|
||||
_scan_duplicate_customers(db, scan)
|
||||
_scan_missing_required_fields(db, scan)
|
||||
_scan_odometer_regressions(db, scan)
|
||||
_scan_booking_overlaps(db, scan)
|
||||
_scan_vehicle_status_conflicts(db, scan)
|
||||
if actor_label is not None:
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type=actor_type,
|
||||
actor_label=actor_label,
|
||||
action="data_quality_scan_run",
|
||||
entity_type="system",
|
||||
metadata={"created": scan.created},
|
||||
)
|
||||
db.commit()
|
||||
return scan
|
||||
|
||||
@@ -344,6 +374,390 @@ def reject_issue(db: Session, public_ref: str, actor: CurrentUser) -> DataQualit
|
||||
return issue
|
||||
|
||||
|
||||
def provide_missing_fields(
|
||||
db: Session, public_ref: str, fields: dict[str, str], actor: CurrentUser
|
||||
) -> DataQualityIssue:
|
||||
issue = _load_open_issue(db, public_ref)
|
||||
if issue.rule_type != "missing_required_field":
|
||||
raise AppError(
|
||||
"NOT_A_MISSING_FIELD_ISSUE",
|
||||
"This issue is not a missing-required-field issue.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
entity: Customer | Vehicle | None
|
||||
if issue.entity_type == "customer":
|
||||
entity = db.get(Customer, issue.entity_id)
|
||||
allowed = {*REQUIRED_CUSTOMER_FIELDS, "email", "phone"}
|
||||
elif issue.entity_type == "vehicle":
|
||||
entity = db.get(Vehicle, issue.entity_id)
|
||||
allowed = set(REQUIRED_VEHICLE_FIELDS)
|
||||
else:
|
||||
raise AppError(
|
||||
"UNSUPPORTED_ENTITY",
|
||||
f"Cannot provide fields for entity type '{issue.entity_type}'.",
|
||||
status_code=409,
|
||||
)
|
||||
if entity is None:
|
||||
raise AppError(
|
||||
"ENTITY_NOT_FOUND", "The underlying record could not be found.", status_code=404
|
||||
)
|
||||
|
||||
invalid = set(fields) - allowed
|
||||
if invalid:
|
||||
raise AppError(
|
||||
"INVALID_FIELD",
|
||||
f"Fields not permitted here: {', '.join(sorted(invalid))}.",
|
||||
status_code=422,
|
||||
)
|
||||
if not fields:
|
||||
raise AppError(
|
||||
"NO_FIELDS_PROVIDED", "At least one field must be provided.", status_code=422
|
||||
)
|
||||
|
||||
before = {f: getattr(entity, f) for f in allowed}
|
||||
for field_name, value in fields.items():
|
||||
if not value.strip():
|
||||
raise AppError("EMPTY_VALUE", f"Field '{field_name}' cannot be blank.", status_code=422)
|
||||
setattr(entity, field_name, value.strip())
|
||||
after = {f: getattr(entity, f) for f in allowed}
|
||||
|
||||
correlation_id = uuid.uuid4()
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_fields_provided",
|
||||
entity_type=issue.entity_type,
|
||||
entity_id=entity.id,
|
||||
correlation_id=correlation_id,
|
||||
before=before,
|
||||
after=after,
|
||||
metadata={"issue_ref": issue.public_ref},
|
||||
)
|
||||
|
||||
if isinstance(entity, Customer):
|
||||
missing = [f for f in REQUIRED_CUSTOMER_FIELDS if not getattr(entity, f)]
|
||||
if not entity.email and not entity.phone:
|
||||
missing.append("email_or_phone")
|
||||
else:
|
||||
missing = [f for f in REQUIRED_VEHICLE_FIELDS if not getattr(entity, f)]
|
||||
|
||||
if not missing:
|
||||
issue.status = "resolved"
|
||||
issue.resolved_at = datetime.now(UTC)
|
||||
issue.resolved_by = actor.display_name
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_issue_resolved",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
correlation_id=correlation_id,
|
||||
before={"status": "open"},
|
||||
after={"status": "resolved"},
|
||||
)
|
||||
else:
|
||||
issue.evidence_json = {**issue.evidence_json, "summary": f"Missing: {', '.join(missing)}"}
|
||||
|
||||
db.commit()
|
||||
return issue
|
||||
|
||||
|
||||
def resolve_odometer_regression(
|
||||
db: Session, public_ref: str, body: ResolveOdometerRegressionRequest, actor: CurrentUser
|
||||
) -> DataQualityIssue:
|
||||
issue = _load_open_issue(db, public_ref)
|
||||
if issue.rule_type != "odometer_regression":
|
||||
raise AppError(
|
||||
"NOT_AN_ODOMETER_ISSUE",
|
||||
"This issue is not an odometer_regression issue.",
|
||||
status_code=409,
|
||||
)
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
|
||||
if vehicle is None:
|
||||
raise AppError(
|
||||
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
||||
)
|
||||
|
||||
correlation_id = uuid.uuid4()
|
||||
|
||||
if body.decision == "retain_canonical":
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_odometer_retained",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
correlation_id=correlation_id,
|
||||
metadata={"issue_ref": issue.public_ref, "canonical_odometer_km": vehicle.odometer_km},
|
||||
)
|
||||
else:
|
||||
related_refs = issue.evidence_json.get("related_refs", [])
|
||||
if body.booking_ref not in related_refs:
|
||||
raise AppError(
|
||||
"INVALID_BOOKING_REFERENCE",
|
||||
"booking_ref must be one of this issue's related bookings.",
|
||||
status_code=422,
|
||||
)
|
||||
if body.corrected_odometer_km is None:
|
||||
raise AppError(
|
||||
"CORRECTED_VALUE_REQUIRED",
|
||||
"corrected_odometer_km is required when correcting a reading.",
|
||||
status_code=422,
|
||||
)
|
||||
# Never silently lower the canonical odometer: a correction must be at or above
|
||||
# the current canonical value, otherwise it would just create a new regression.
|
||||
if body.corrected_odometer_km < vehicle.odometer_km:
|
||||
raise AppError(
|
||||
"CORRECTION_BELOW_CANONICAL",
|
||||
(
|
||||
f"Corrected value {body.corrected_odometer_km} km is still below the "
|
||||
f"canonical {vehicle.odometer_km} km; it would not resolve the regression."
|
||||
),
|
||||
status_code=422,
|
||||
)
|
||||
booking = db.scalar(
|
||||
select(Booking).where(Booking.public_ref == body.booking_ref).with_for_update()
|
||||
)
|
||||
if booking is None:
|
||||
raise AppError(
|
||||
"BOOKING_NOT_FOUND", "The booking to correct was not found.", status_code=404
|
||||
)
|
||||
|
||||
before = {
|
||||
"booking_end_odometer_km": booking.end_odometer_km,
|
||||
"vehicle_odometer_km": vehicle.odometer_km,
|
||||
}
|
||||
booking.end_odometer_km = body.corrected_odometer_km
|
||||
vehicle.odometer_km = body.corrected_odometer_km
|
||||
vehicle.version += 1
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_odometer_corrected",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
correlation_id=correlation_id,
|
||||
before=before,
|
||||
after={
|
||||
"booking_end_odometer_km": booking.end_odometer_km,
|
||||
"vehicle_odometer_km": vehicle.odometer_km,
|
||||
},
|
||||
metadata={"issue_ref": issue.public_ref, "booking_ref": booking.public_ref},
|
||||
)
|
||||
|
||||
issue.status = "resolved"
|
||||
issue.resolved_at = datetime.now(UTC)
|
||||
issue.resolved_by = actor.display_name
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_issue_resolved",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
correlation_id=correlation_id,
|
||||
before={"status": "open"},
|
||||
after={"status": "resolved"},
|
||||
metadata={"decision": body.decision, "note": body.note},
|
||||
)
|
||||
db.commit()
|
||||
return issue
|
||||
|
||||
|
||||
def resolve_booking_overlap(
|
||||
db: Session, public_ref: str, booking_ref: str, note: str | None, actor: CurrentUser
|
||||
) -> DataQualityIssue:
|
||||
issue = _load_open_issue(db, public_ref)
|
||||
if issue.rule_type != "booking_overlap":
|
||||
raise AppError(
|
||||
"NOT_AN_OVERLAP_ISSUE", "This issue is not a booking_overlap issue.", status_code=409
|
||||
)
|
||||
related_refs = issue.evidence_json.get("related_refs", [])
|
||||
if booking_ref not in related_refs:
|
||||
raise AppError(
|
||||
"INVALID_BOOKING_REFERENCE",
|
||||
"booking_ref must be one of this issue's overlapping bookings.",
|
||||
status_code=422,
|
||||
)
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == booking_ref).with_for_update())
|
||||
if booking is None:
|
||||
raise AppError("BOOKING_NOT_FOUND", "The booking to block was not found.", status_code=404)
|
||||
if booking.status not in ("reserved", "active"):
|
||||
raise AppError(
|
||||
"BOOKING_NOT_ACTIVE",
|
||||
f"Booking is '{booking.status}'; only a reserved or active booking can be blocked.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
before = {"status": booking.status}
|
||||
booking.status = "blocked"
|
||||
|
||||
# Verify the minimal safe resolution actually removed the conflict: no two
|
||||
# reserved/active bookings for this vehicle should still overlap. The session has
|
||||
# autoflush disabled, so exclude the just-blocked booking by id rather than relying
|
||||
# on the in-memory status change being visible to this query.
|
||||
remaining = db.scalars(
|
||||
select(Booking).where(
|
||||
Booking.vehicle_id == booking.vehicle_id,
|
||||
Booking.status.in_(["reserved", "active"]),
|
||||
Booking.public_ref.in_(related_refs),
|
||||
Booking.id != booking.id,
|
||||
)
|
||||
).all()
|
||||
for i, first in enumerate(remaining):
|
||||
for second in remaining[i + 1 :]:
|
||||
if second.starts_at < first.ends_at and first.starts_at < second.ends_at:
|
||||
raise AppError(
|
||||
"OVERLAP_STILL_PRESENT",
|
||||
"Blocking this booking did not remove the overlap; another commitment remains.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
correlation_id = uuid.uuid4()
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_booking_blocked",
|
||||
entity_type="booking",
|
||||
entity_id=booking.id,
|
||||
correlation_id=correlation_id,
|
||||
before=before,
|
||||
after={"status": booking.status},
|
||||
metadata={"issue_ref": issue.public_ref, "note": note},
|
||||
)
|
||||
|
||||
issue.status = "resolved"
|
||||
issue.resolved_at = datetime.now(UTC)
|
||||
issue.resolved_by = actor.display_name
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_issue_resolved",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
correlation_id=correlation_id,
|
||||
before={"status": "open"},
|
||||
after={"status": "resolved"},
|
||||
)
|
||||
db.commit()
|
||||
return issue
|
||||
|
||||
|
||||
def _recommend_vehicle_status(
|
||||
operational_status: str, has_active_booking: bool, has_open_high_issue: bool
|
||||
) -> tuple[str, str] | None:
|
||||
"""The single authoritative recommendation function for vehicle_status_conflict,
|
||||
mirroring the exact conditions `_scan_vehicle_status_conflicts` flags."""
|
||||
if operational_status == "available" and has_active_booking:
|
||||
return "rented", "An active booking exists; the vehicle should be marked rented."
|
||||
if operational_status == "rented" and not has_active_booking:
|
||||
return "available", "No active booking exists; the vehicle should be marked available."
|
||||
if operational_status == "available" and has_open_high_issue:
|
||||
return "blocked", "A high-severity quality issue is open; the vehicle should be blocked."
|
||||
if operational_status == "maintenance" and has_active_booking:
|
||||
return (
|
||||
"rented",
|
||||
"An active booking exists despite the maintenance status; it should be rented.",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def apply_recommended_status(
|
||||
db: Session, public_ref: str, actor: CurrentUser
|
||||
) -> tuple[DataQualityIssue, str, str]:
|
||||
issue = _load_open_issue(db, public_ref)
|
||||
if issue.rule_type != "vehicle_status_conflict":
|
||||
raise AppError(
|
||||
"NOT_A_STATUS_CONFLICT_ISSUE",
|
||||
"This issue is not a vehicle_status_conflict issue.",
|
||||
status_code=409,
|
||||
)
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
|
||||
if vehicle is None:
|
||||
raise AppError(
|
||||
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
||||
)
|
||||
|
||||
has_active_booking = (
|
||||
db.scalar(
|
||||
select(Booking.id).where(Booking.vehicle_id == vehicle.id, Booking.status == "active")
|
||||
)
|
||||
is not None
|
||||
)
|
||||
has_open_high_issue = (
|
||||
db.scalar(
|
||||
select(DataQualityIssue.id).where(
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle.id,
|
||||
DataQualityIssue.status == "open",
|
||||
DataQualityIssue.severity == "high",
|
||||
DataQualityIssue.id != issue.id,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
recommendation = _recommend_vehicle_status(
|
||||
vehicle.operational_status, has_active_booking, has_open_high_issue
|
||||
)
|
||||
if recommendation is None:
|
||||
raise AppError(
|
||||
"NO_CONFLICT_DETECTED",
|
||||
"The current vehicle state no longer conflicts; nothing to apply.",
|
||||
status_code=409,
|
||||
)
|
||||
new_status, reason = recommendation
|
||||
|
||||
before = {"operational_status": vehicle.operational_status}
|
||||
vehicle.operational_status = new_status
|
||||
vehicle.version += 1
|
||||
|
||||
# Re-validate: the same recommendation function must find no further conflict.
|
||||
if _recommend_vehicle_status(new_status, has_active_booking, has_open_high_issue) is not None:
|
||||
raise AppError(
|
||||
"CONFLICT_STILL_PRESENT",
|
||||
"Applying the recommended status did not resolve the conflict.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
correlation_id = uuid.uuid4()
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_status_applied",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
correlation_id=correlation_id,
|
||||
before=before,
|
||||
after={"operational_status": vehicle.operational_status},
|
||||
metadata={"issue_ref": issue.public_ref, "reason": reason},
|
||||
)
|
||||
|
||||
issue.status = "resolved"
|
||||
issue.resolved_at = datetime.now(UTC)
|
||||
issue.resolved_by = actor.display_name
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_issue_resolved",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
correlation_id=correlation_id,
|
||||
before={"status": "open"},
|
||||
after={"status": "resolved"},
|
||||
)
|
||||
db.commit()
|
||||
return issue, new_status, reason
|
||||
|
||||
|
||||
MERGEABLE_FIELDS = ("first_name", "last_name", "email", "phone", "postal_code", "city")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.booking import Booking
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut
|
||||
from app.services.integration_status import derive_n8n_status
|
||||
from app.services.knowledge import get_knowledge_provider
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
_FAILED_DEMO_EVENT_ID = "00000000-0000-4000-8000-000000000020"
|
||||
|
||||
|
||||
def _last_reset(db: Session) -> tuple[datetime | None, str | None]:
|
||||
marker = db.scalar(
|
||||
select(AuditEvent)
|
||||
.where(AuditEvent.action == "demo_data_seeded")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
)
|
||||
if marker is None:
|
||||
return None, None
|
||||
metadata = marker.metadata_json or {}
|
||||
return marker.occurred_at, metadata.get("anchor_date")
|
||||
|
||||
|
||||
def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == "BK-DEMO-RETURN"))
|
||||
duplicate_issue = db.scalar(
|
||||
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-DUPLICATE")
|
||||
)
|
||||
overlap_issue = db.scalar(
|
||||
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP")
|
||||
)
|
||||
failed_run = db.scalar(
|
||||
select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID)
|
||||
)
|
||||
knowledge_health = get_knowledge_provider().health()
|
||||
|
||||
# Human copy (title, problem statement, "demonstrates" summary) lives entirely in the
|
||||
# frontend's demo.json (scenarios.items.<id>.*) so it's available in all three UI
|
||||
# languages. This service only emits stable identifiers and message codes -- never
|
||||
# display prose -- per the message_code + params architecture used across the app.
|
||||
return_ready = bool(
|
||||
booking and booking.status == "active" and booking.end_odometer_km is None
|
||||
)
|
||||
duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open")
|
||||
overlap_ready = bool(overlap_issue and overlap_issue.status == "open")
|
||||
automation_ready = bool(failed_run and failed_run.delivery_status == "failed")
|
||||
|
||||
return [
|
||||
DemoScenarioOut(
|
||||
id="return-anomaly",
|
||||
estimated_minutes=3,
|
||||
required_roles=["rental_employee", "operations_manager"],
|
||||
start_path=f"/bookings/{booking.public_ref}" if booking else "/bookings",
|
||||
ready=return_ready,
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if return_ready
|
||||
else "bookingNotFound" if booking is None else "bookingAlreadyProcessed"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
id="duplicate-customer",
|
||||
estimated_minutes=3,
|
||||
required_roles=["operations_manager"],
|
||||
start_path=(
|
||||
f"/data-quality/{duplicate_issue.public_ref}"
|
||||
if duplicate_issue
|
||||
else "/data-quality"
|
||||
),
|
||||
ready=duplicate_ready,
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if duplicate_ready
|
||||
else "duplicateIssueNotFound" if duplicate_issue is None else "issueAlreadyResolved"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
id="booking-overlap",
|
||||
estimated_minutes=2,
|
||||
required_roles=["operations_manager"],
|
||||
start_path=(
|
||||
f"/data-quality/{overlap_issue.public_ref}" if overlap_issue else "/data-quality"
|
||||
),
|
||||
ready=overlap_ready,
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if overlap_ready
|
||||
else "overlapIssueNotFound" if overlap_issue is None else "issueAlreadyResolved"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
id="automation-retry",
|
||||
estimated_minutes=2,
|
||||
required_roles=["operations_manager"],
|
||||
start_path="/automation",
|
||||
ready=automation_ready,
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if automation_ready
|
||||
else "failedEventNotFound" if failed_run is None else "eventAlreadyRecovered"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
id="knowledge-question",
|
||||
estimated_minutes=2,
|
||||
required_roles=["rental_employee", "operations_manager"],
|
||||
start_path="/knowledge",
|
||||
ready=knowledge_health.available,
|
||||
blocked_reason_code=None if knowledge_health.available else "knowledgeUnavailable",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
||||
n8n = derive_n8n_status(db)
|
||||
knowledge_health = get_knowledge_provider().health()
|
||||
|
||||
return [
|
||||
DemoIntegrationSummaryOut(
|
||||
key="n8n",
|
||||
status_code=n8n.state,
|
||||
detail_code="n8nDetail",
|
||||
detail_params={
|
||||
"succeeded": n8n.succeeded,
|
||||
"failed": n8n.failed,
|
||||
"pending": n8n.pending,
|
||||
},
|
||||
),
|
||||
DemoIntegrationSummaryOut(
|
||||
key="ragcore",
|
||||
status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode",
|
||||
detail_code="ragcoreDetail",
|
||||
detail_params={
|
||||
"count": knowledge_health.document_count,
|
||||
"collection": knowledge_health.collection,
|
||||
},
|
||||
),
|
||||
DemoIntegrationSummaryOut(
|
||||
key="mcp_hub",
|
||||
status_code="operational" if settings.mcp_hub_registration_enabled else "notConnected",
|
||||
detail_code=(
|
||||
"mcpDetailEnabled"
|
||||
if settings.mcp_hub_registration_enabled
|
||||
else "mcpDetailNotConnected"
|
||||
),
|
||||
detail_params={},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def scenario_integrity_report(db: Session) -> dict:
|
||||
"""Server-side scenario-integrity check run after every reset (section 15): confirms
|
||||
each of the 5 named scenarios is actually present and ready, rather than trusting the
|
||||
seed loader silently. Reuses the same readiness derivation the manifest/scenario
|
||||
overview already use, so this can never drift from what a visitor actually sees."""
|
||||
scenarios = _scenarios(db)
|
||||
not_ready = [
|
||||
{"id": s.id, "reason_code": s.blocked_reason_code}
|
||||
for s in scenarios
|
||||
if not s.ready
|
||||
]
|
||||
return {"all_ready": len(not_ready) == 0, "not_ready": not_ready}
|
||||
|
||||
|
||||
def build_demo_manifest(db: Session) -> DemoManifestOut:
|
||||
last_reset_at, anchor_date = _last_reset(db)
|
||||
return DemoManifestOut(
|
||||
demo_mode=settings.mobilityops_demo_mode,
|
||||
organization_name=settings.demo_organization_name,
|
||||
timezone=settings.demo_timezone,
|
||||
synthetic_data=True,
|
||||
allow_reset=settings.demo_allow_reset,
|
||||
last_reset_at=last_reset_at,
|
||||
anchor_date=anchor_date,
|
||||
guide_available=True,
|
||||
required_roles=["operations_manager", "rental_employee"],
|
||||
scenarios=_scenarios(db),
|
||||
integrations=_integrations(db),
|
||||
)
|
||||
@@ -22,8 +22,42 @@ def _backoff_seconds(attempts: int) -> int:
|
||||
return min(2**attempts, 60)
|
||||
|
||||
|
||||
def _reclaim_stale_deliveries(batch_size: int = 10) -> int:
|
||||
"""Recover events stuck in 'delivering' because the process that claimed them died
|
||||
before recording an outcome. Only leases whose deadline has passed are touched, so an
|
||||
in-flight delivery from a still-alive worker is never disturbed or double-processed;
|
||||
`attempts` is preserved so the count reflects true history."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
rows = db.scalars(
|
||||
select(OutboxEvent)
|
||||
.where(
|
||||
OutboxEvent.delivery_status == "delivering",
|
||||
OutboxEvent.next_attempt_at.is_not(None),
|
||||
OutboxEvent.next_attempt_at <= now,
|
||||
)
|
||||
.limit(batch_size)
|
||||
.with_for_update(skip_locked=True)
|
||||
).all()
|
||||
for row in rows:
|
||||
row.delivery_status = "pending"
|
||||
row.next_attempt_at = None
|
||||
row.last_error = (
|
||||
"Recovered from a stale 'delivering' lease "
|
||||
f"(no outcome recorded within {settings.n8n_delivery_lease_seconds:.0f}s; "
|
||||
f"the process likely crashed mid-delivery). attempts preserved at {row.attempts}."
|
||||
)[:2000]
|
||||
db.commit()
|
||||
return len(rows)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _claim_due_events(batch_size: int = 5) -> list[uuid.UUID]:
|
||||
"""Claim a batch of due events with a short-lived transaction (no network I/O held open)."""
|
||||
"""Claim a batch of due events with a short-lived transaction (no network I/O held open).
|
||||
Each claimed row gets a lease deadline (next_attempt_at) so a crash between this claim
|
||||
and the outcome being recorded is recoverable by _reclaim_stale_deliveries."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
@@ -38,8 +72,10 @@ def _claim_due_events(batch_size: int = 5) -> list[uuid.UUID]:
|
||||
.with_for_update(skip_locked=True)
|
||||
).all()
|
||||
claimed_ids = [row.event_id for row in rows]
|
||||
lease_deadline = now + timedelta(seconds=settings.n8n_delivery_lease_seconds)
|
||||
for row in rows:
|
||||
row.delivery_status = "delivering"
|
||||
row.next_attempt_at = lease_deadline
|
||||
db.commit()
|
||||
return claimed_ids
|
||||
finally:
|
||||
@@ -120,7 +156,8 @@ def _deliver_one(event_id: uuid.UUID) -> None:
|
||||
|
||||
|
||||
def run_dispatch_cycle() -> int:
|
||||
"""Run one claim+deliver cycle. Returns the number of events processed."""
|
||||
"""Run one reclaim+claim+deliver cycle. Returns the number of events processed."""
|
||||
_reclaim_stale_deliveries()
|
||||
claimed = _claim_due_events()
|
||||
for event_id in claimed:
|
||||
_deliver_one(event_id)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.schemas import N8nIntegrationStatus
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
counts: dict[str, int] = dict(
|
||||
db.execute(
|
||||
select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status)
|
||||
).all() # type: ignore[arg-type]
|
||||
)
|
||||
pending = counts.get("pending", 0)
|
||||
delivering = counts.get("delivering", 0)
|
||||
failed = counts.get("failed", 0)
|
||||
succeeded = counts.get("succeeded", 0)
|
||||
|
||||
latest_success_at = db.scalar(
|
||||
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
|
||||
)
|
||||
latest_failure_at = db.scalar(
|
||||
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed")
|
||||
)
|
||||
|
||||
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||
if not settings.n8n_dispatch_enabled:
|
||||
state = "disabled"
|
||||
elif failed > 0 and succeeded == 0:
|
||||
state = "unavailable"
|
||||
elif failed > 0:
|
||||
state = "degraded"
|
||||
elif succeeded > 0 or pending > 0 or delivering > 0:
|
||||
state = "operational"
|
||||
else:
|
||||
state = "no_evidence"
|
||||
|
||||
return N8nIntegrationStatus(
|
||||
configured=bool(settings.n8n_webhook_url),
|
||||
dispatch_enabled=settings.n8n_dispatch_enabled,
|
||||
state=state,
|
||||
pending=pending,
|
||||
delivering=delivering,
|
||||
failed=failed,
|
||||
succeeded=succeeded,
|
||||
latest_success_at=latest_success_at,
|
||||
latest_failure_at=latest_failure_at,
|
||||
)
|
||||
@@ -39,9 +39,11 @@ class KnowledgeHealth(BaseModel):
|
||||
class KnowledgeProvider(Protocol):
|
||||
name: str
|
||||
|
||||
def health(self) -> KnowledgeHealth: ...
|
||||
def health(self, language: str = "en-GB") -> KnowledgeHealth: ...
|
||||
|
||||
def ask(self, question: str, correlation_id: str) -> GroundedAnswer: ...
|
||||
def ask(
|
||||
self, question: str, correlation_id: str, language: str = "en-GB"
|
||||
) -> GroundedAnswer: ...
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -8,12 +8,31 @@ from pathlib import Path
|
||||
from app.core.config import get_settings
|
||||
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
|
||||
|
||||
STOPWORDS = {
|
||||
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
||||
DEFAULT_LANGUAGE = "en-GB"
|
||||
|
||||
STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = {
|
||||
"en-GB": {
|
||||
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
|
||||
"to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then",
|
||||
"do", "does", "did", "must", "may", "can", "could", "should", "would",
|
||||
"i", "you", "it", "we", "they", "my", "your", "what", "when", "how",
|
||||
"with", "without", "this", "that", "these", "those", "not", "no",
|
||||
},
|
||||
"nl-BE": {
|
||||
"een", "de", "het", "is", "zijn", "was", "waren", "worden", "wordt",
|
||||
"van", "in", "op", "voor", "en", "of", "maar", "als", "dan",
|
||||
"moet", "mag", "kan", "kunnen", "zou", "zouden",
|
||||
"ik", "jij", "u", "we", "wij", "zij", "mijn", "jouw", "wat", "wanneer", "hoe",
|
||||
"met", "zonder", "dit", "dat", "deze", "die", "niet", "geen",
|
||||
},
|
||||
"fr-BE": {
|
||||
"un", "une", "le", "la", "les", "des", "est", "sont", "était", "être",
|
||||
"de", "du", "en", "sur", "pour", "et", "ou", "mais", "si", "alors",
|
||||
"doit", "peut", "peuvent", "pourrait", "devrait",
|
||||
"je", "tu", "vous", "il", "elle", "nous", "ils", "mon", "votre", "quoi", "quand", "comment",
|
||||
"avec", "sans", "ce", "cette", "ces", "cela", "pas", "non",
|
||||
},
|
||||
}
|
||||
|
||||
_WORD_RE = re.compile(r"[a-z0-9]+")
|
||||
@@ -28,9 +47,10 @@ def _stem(word: str) -> str:
|
||||
return word
|
||||
|
||||
|
||||
def _tokenize(text: str) -> set[str]:
|
||||
def _tokenize(text: str, language: str) -> set[str]:
|
||||
stopwords = STOPWORDS_BY_LANGUAGE.get(language, STOPWORDS_BY_LANGUAGE[DEFAULT_LANGUAGE])
|
||||
words = _WORD_RE.findall(text.lower())
|
||||
return {_stem(w) for w in words if w not in STOPWORDS and len(w) > 2}
|
||||
return {_stem(w) for w in words if w not in stopwords and len(w) > 2}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -86,7 +106,7 @@ def _split_sections(body: str) -> list[tuple[str, str]]:
|
||||
return sections
|
||||
|
||||
|
||||
def _load_sections(procedures_dir: Path) -> list[ScoredSection]:
|
||||
def _load_sections(procedures_dir: Path, language: str) -> list[ScoredSection]:
|
||||
sections: list[ScoredSection] = []
|
||||
for path in sorted(procedures_dir.glob("*.md")):
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
@@ -96,7 +116,7 @@ def _load_sections(procedures_dir: Path) -> list[ScoredSection]:
|
||||
document_id=meta.get("document_id", path.stem),
|
||||
title=title,
|
||||
version=meta.get("version", "1.0"),
|
||||
title_tokens=_tokenize(title),
|
||||
title_tokens=_tokenize(title, language),
|
||||
)
|
||||
for heading, text in _split_sections(body):
|
||||
sections.append(
|
||||
@@ -104,19 +124,49 @@ def _load_sections(procedures_dir: Path) -> list[ScoredSection]:
|
||||
document=doc,
|
||||
heading=heading,
|
||||
text=text,
|
||||
heading_tokens=_tokenize(heading),
|
||||
body_tokens=_tokenize(text),
|
||||
heading_tokens=_tokenize(heading, language),
|
||||
body_tokens=_tokenize(text, language),
|
||||
)
|
||||
)
|
||||
return sections
|
||||
|
||||
|
||||
_NO_MATCH_TEXT = {
|
||||
"en-GB": "No matching procedure was found for this question.",
|
||||
"nl-BE": "Er werd geen passende procedure gevonden voor deze vraag.",
|
||||
"fr-BE": "Aucune procédure correspondante n'a été trouvée pour cette question.",
|
||||
}
|
||||
_LOW_CONFIDENCE_TEXT = {
|
||||
"en-GB": (
|
||||
"The available procedures do not clearly answer this question. "
|
||||
"The closest matches are included below for review."
|
||||
),
|
||||
"nl-BE": (
|
||||
"De beschikbare procedures beantwoorden deze vraag niet duidelijk. "
|
||||
"De dichtstbijzijnde overeenkomsten staan hieronder ter beoordeling."
|
||||
),
|
||||
"fr-BE": (
|
||||
"Les procédures disponibles ne répondent pas clairement à cette question. "
|
||||
"Les correspondances les plus proches sont indiquées ci-dessous pour examen."
|
||||
),
|
||||
}
|
||||
_LEAD_ANSWER_TEMPLATE = {
|
||||
"en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}',
|
||||
"nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}',
|
||||
"fr-BE": 'Selon « {title} » (v{version}), section « {heading} » : {excerpt}',
|
||||
}
|
||||
|
||||
|
||||
class DemoKnowledgeProvider:
|
||||
"""Deterministic extractive retrieval over the local procedure Markdown files.
|
||||
|
||||
Not a generative model: it scores sections with TF-IDF-weighted keyword overlap
|
||||
(downweighting terms common across the whole corpus, like "vehicle", in favor of
|
||||
distinctive ones, like "damage") and returns real excerpts, never invented text.
|
||||
|
||||
Each supported UI language has its own translated procedure corpus under
|
||||
knowledge/procedures/<language>/ -- retrieval searches only within the requested
|
||||
language's corpus so citations always link to a same-language document.
|
||||
"""
|
||||
|
||||
name = "demo"
|
||||
@@ -124,10 +174,18 @@ class DemoKnowledgeProvider:
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
self._settings = settings
|
||||
self._procedures_dir = Path(settings.knowledge_dir)
|
||||
self._sections = _load_sections(self._procedures_dir)
|
||||
self._document_count = len({s.document.document_id for s in self._sections})
|
||||
self._idf = self._build_idf(self._sections)
|
||||
base_dir = Path(settings.knowledge_dir)
|
||||
self._sections_by_language: dict[str, list[ScoredSection]] = {}
|
||||
self._idf_by_language: dict[str, dict[str, float]] = {}
|
||||
self._document_count_by_language: dict[str, int] = {}
|
||||
for language in SUPPORTED_LANGUAGES:
|
||||
lang_dir = base_dir / language
|
||||
sections = _load_sections(lang_dir, language) if lang_dir.is_dir() else []
|
||||
self._sections_by_language[language] = sections
|
||||
self._idf_by_language[language] = self._build_idf(sections)
|
||||
self._document_count_by_language[language] = len(
|
||||
{s.document.document_id for s in sections}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_idf(sections: list[ScoredSection]) -> dict[str, float]:
|
||||
@@ -140,7 +198,13 @@ class DemoKnowledgeProvider:
|
||||
doc_freq[token] = doc_freq.get(token, 0) + 1
|
||||
return {token: math.log((n + 1) / (df + 1)) + 1 for token, df in doc_freq.items()}
|
||||
|
||||
def health(self) -> KnowledgeHealth:
|
||||
def _normalize_language(self, language: str | None) -> str:
|
||||
if language in SUPPORTED_LANGUAGES:
|
||||
return language
|
||||
return DEFAULT_LANGUAGE
|
||||
|
||||
def health(self, language: str = DEFAULT_LANGUAGE) -> KnowledgeHealth:
|
||||
language = self._normalize_language(language)
|
||||
return KnowledgeHealth(
|
||||
provider=self.name,
|
||||
available=True,
|
||||
@@ -148,36 +212,40 @@ class DemoKnowledgeProvider:
|
||||
tenant=self._settings.ragcore_tenant,
|
||||
workspace=self._settings.ragcore_workspace,
|
||||
collection=self._settings.ragcore_collection,
|
||||
document_count=self._document_count,
|
||||
document_count=self._document_count_by_language[language],
|
||||
)
|
||||
|
||||
def _score(self, query_tokens: set[str], section: ScoredSection) -> float:
|
||||
def _score(
|
||||
self, query_tokens: set[str], section: ScoredSection, idf: dict[str, float]
|
||||
) -> float:
|
||||
score = 0.0
|
||||
for token in query_tokens:
|
||||
idf = self._idf.get(token, 0.0)
|
||||
if idf == 0.0:
|
||||
token_idf = idf.get(token, 0.0)
|
||||
if token_idf == 0.0:
|
||||
continue
|
||||
if token in section.heading_tokens:
|
||||
score += 3 * idf
|
||||
score += 3 * token_idf
|
||||
elif token in section.document.title_tokens:
|
||||
score += 2 * idf
|
||||
score += 2 * token_idf
|
||||
elif token in section.body_tokens:
|
||||
score += idf
|
||||
score += token_idf
|
||||
return score
|
||||
|
||||
def ask(self, question: str, correlation_id: str) -> GroundedAnswer:
|
||||
query_tokens = _tokenize(question)
|
||||
scored = [
|
||||
(self._score(query_tokens, section), section)
|
||||
for section in self._sections
|
||||
]
|
||||
def ask(
|
||||
self, question: str, correlation_id: str, language: str = DEFAULT_LANGUAGE
|
||||
) -> GroundedAnswer:
|
||||
language = self._normalize_language(language)
|
||||
sections = self._sections_by_language[language]
|
||||
idf = self._idf_by_language[language]
|
||||
query_tokens = _tokenize(question, language)
|
||||
scored = [(self._score(query_tokens, section, idf), section) for section in sections]
|
||||
scored = [(score, section) for score, section in scored if score > 0]
|
||||
scored.sort(key=lambda item: item[0], reverse=True)
|
||||
top = scored[:3]
|
||||
|
||||
if not top:
|
||||
return GroundedAnswer(
|
||||
answer="No matching procedure was found for this question.",
|
||||
answer=_NO_MATCH_TEXT[language],
|
||||
evidence_state="insufficient",
|
||||
sources=[],
|
||||
provider=self.name,
|
||||
@@ -197,10 +265,7 @@ class DemoKnowledgeProvider:
|
||||
|
||||
if top[0][0] < 3:
|
||||
return GroundedAnswer(
|
||||
answer=(
|
||||
"The available procedures do not clearly answer this question. "
|
||||
"The closest matches are included below for review."
|
||||
),
|
||||
answer=_LOW_CONFIDENCE_TEXT[language],
|
||||
evidence_state="insufficient",
|
||||
sources=sources,
|
||||
provider=self.name,
|
||||
@@ -208,9 +273,11 @@ class DemoKnowledgeProvider:
|
||||
)
|
||||
|
||||
lead_section = top[0][1]
|
||||
answer = (
|
||||
f'Per "{lead_section.document.title}" (v{lead_section.document.version}), '
|
||||
f'section "{lead_section.heading}": {lead_section.text.splitlines()[0][:300]}'
|
||||
answer = _LEAD_ANSWER_TEMPLATE[language].format(
|
||||
title=lead_section.document.title,
|
||||
version=lead_section.document.version,
|
||||
heading=lead_section.heading,
|
||||
excerpt=lead_section.text.splitlines()[0][:300],
|
||||
)
|
||||
return GroundedAnswer(
|
||||
answer=answer,
|
||||
|
||||
@@ -32,7 +32,7 @@ class RAGcoreKnowledgeProvider:
|
||||
timeout=self._settings.ragcore_http_timeout_seconds,
|
||||
)
|
||||
|
||||
def health(self) -> KnowledgeHealth:
|
||||
def health(self, language: str = "en-GB") -> KnowledgeHealth:
|
||||
try:
|
||||
with self._client() as client:
|
||||
response = client.get("/health")
|
||||
@@ -52,7 +52,7 @@ class RAGcoreKnowledgeProvider:
|
||||
document_count=0,
|
||||
)
|
||||
|
||||
def ask(self, question: str, correlation_id: str) -> GroundedAnswer:
|
||||
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
|
||||
try:
|
||||
with self._client() as client:
|
||||
response = client.post(
|
||||
@@ -63,6 +63,7 @@ class RAGcoreKnowledgeProvider:
|
||||
"collection": self._settings.ragcore_collection,
|
||||
"question": question,
|
||||
"correlation_id": correlation_id,
|
||||
"language": language,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -25,12 +26,120 @@ def _next_public_ref(db: Session) -> str:
|
||||
return f"{REF_PREFIX}-{len(existing) + 1:04d}"
|
||||
|
||||
|
||||
def _derive_vehicle_status(body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int) -> str:
|
||||
if body.damage_reported or body.technical_warning:
|
||||
return "blocked"
|
||||
def _derive_vehicle_status_with_reason(
|
||||
body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int
|
||||
) -> tuple[str, str]:
|
||||
if body.damage_reported and body.technical_warning:
|
||||
return "blocked", "Damage and a technical warning were both reported on return."
|
||||
if body.damage_reported:
|
||||
return "blocked", "Damage was reported on return."
|
||||
if body.technical_warning:
|
||||
return "blocked", "A technical warning was reported on return."
|
||||
if new_odometer >= vehicle.next_service_km:
|
||||
return "maintenance"
|
||||
return "cleaning"
|
||||
return (
|
||||
"maintenance",
|
||||
f"Odometer reached the {vehicle.next_service_km:,} km service threshold.",
|
||||
)
|
||||
return "cleaning", "No damage, technical warning or service threshold; routed to cleaning."
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReturnEvaluation:
|
||||
canonical_odometer_km: int
|
||||
submitted_odometer_km: int
|
||||
odometer_regression: bool
|
||||
resulting_odometer_km: int
|
||||
resulting_vehicle_status: str
|
||||
status_reason: str
|
||||
would_create_quality_issue: bool
|
||||
attention_reasons: list[str]
|
||||
next_booking_risk: dict | None
|
||||
|
||||
|
||||
def evaluate_return(
|
||||
db: Session, booking: Booking, vehicle: Vehicle, body: RegisterReturnRequest, *, now: datetime
|
||||
) -> ReturnEvaluation:
|
||||
"""Pure evaluation of what a return would do. No writes; safe to call from a
|
||||
non-mutating preview endpoint. `register_vehicle_return` uses the same function so
|
||||
preview and commit can never drift apart."""
|
||||
odometer_regression = body.end_odometer_km < vehicle.odometer_km
|
||||
resulting_odometer_km = vehicle.odometer_km if odometer_regression else body.end_odometer_km
|
||||
resulting_status, status_reason = _derive_vehicle_status_with_reason(
|
||||
body, vehicle, resulting_odometer_km
|
||||
)
|
||||
|
||||
attention_reasons = []
|
||||
if body.damage_reported:
|
||||
attention_reasons.append("damage_reported")
|
||||
if body.technical_warning:
|
||||
attention_reasons.append("technical_warning")
|
||||
if odometer_regression:
|
||||
attention_reasons.append("odometer_regression")
|
||||
|
||||
next_booking = db.scalar(
|
||||
select(Booking)
|
||||
.where(
|
||||
Booking.vehicle_id == vehicle.id,
|
||||
Booking.status == "reserved",
|
||||
Booking.starts_at > now,
|
||||
)
|
||||
.order_by(Booking.starts_at.asc())
|
||||
)
|
||||
next_booking_risk = None
|
||||
if next_booking is not None:
|
||||
hours_until = (next_booking.starts_at - now).total_seconds() / 3600
|
||||
next_booking_risk = {
|
||||
"booking_ref": next_booking.public_ref,
|
||||
"starts_at": next_booking.starts_at.isoformat(),
|
||||
"at_risk": resulting_status != "cleaning" or hours_until < 4,
|
||||
}
|
||||
|
||||
return ReturnEvaluation(
|
||||
canonical_odometer_km=vehicle.odometer_km,
|
||||
submitted_odometer_km=body.end_odometer_km,
|
||||
odometer_regression=odometer_regression,
|
||||
resulting_odometer_km=resulting_odometer_km,
|
||||
resulting_vehicle_status=resulting_status,
|
||||
status_reason=status_reason,
|
||||
would_create_quality_issue=odometer_regression,
|
||||
attention_reasons=attention_reasons,
|
||||
next_booking_risk=next_booking_risk,
|
||||
)
|
||||
|
||||
|
||||
def _load_active_booking_and_vehicle(
|
||||
db: Session, booking_ref: str, *, lock: bool
|
||||
) -> tuple[Booking, Vehicle]:
|
||||
stmt = select(Booking).where(Booking.public_ref == booking_ref)
|
||||
if lock:
|
||||
stmt = stmt.with_for_update()
|
||||
booking = db.scalar(stmt)
|
||||
if booking is None:
|
||||
raise AppError("BOOKING_NOT_FOUND", "Booking not found.", status_code=404)
|
||||
|
||||
vehicle_stmt = select(Vehicle).where(Vehicle.id == booking.vehicle_id)
|
||||
if lock:
|
||||
vehicle_stmt = vehicle_stmt.with_for_update()
|
||||
vehicle = db.scalar(vehicle_stmt)
|
||||
if vehicle is None:
|
||||
raise AppError(
|
||||
"VEHICLE_NOT_FOUND", "The vehicle for this booking could not be found.", status_code=404
|
||||
)
|
||||
return booking, vehicle
|
||||
|
||||
|
||||
def preview_vehicle_return(
|
||||
db: Session, booking_ref: str, body: RegisterReturnRequest
|
||||
) -> tuple[Booking, Vehicle, ReturnEvaluation]:
|
||||
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=False)
|
||||
if booking.status != "active":
|
||||
raise AppError(
|
||||
"INVALID_BOOKING_STATE",
|
||||
f"Booking is '{booking.status}', not 'active'; it cannot be returned.",
|
||||
status_code=409,
|
||||
)
|
||||
evaluation = evaluate_return(db, booking, vehicle, body, now=datetime.now(UTC))
|
||||
return booking, vehicle, evaluation
|
||||
|
||||
|
||||
def register_vehicle_return(
|
||||
@@ -53,14 +162,7 @@ def register_vehicle_return(
|
||||
)
|
||||
return existing.response_status, existing.response_body
|
||||
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == booking_ref).with_for_update())
|
||||
if booking is None:
|
||||
raise AppError("BOOKING_NOT_FOUND", "Booking not found.", status_code=404)
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
|
||||
if vehicle is None:
|
||||
raise AppError(
|
||||
"VEHICLE_NOT_FOUND", "The vehicle for this booking could not be found.", status_code=404
|
||||
)
|
||||
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True)
|
||||
|
||||
# Re-check after acquiring the row lock: a concurrent identical-key request may have
|
||||
# just committed while we were waiting.
|
||||
@@ -79,6 +181,7 @@ def register_vehicle_return(
|
||||
|
||||
now = datetime.now(UTC)
|
||||
correlation_id = uuid.uuid4()
|
||||
evaluation = evaluate_return(db, booking, vehicle, body, now=now)
|
||||
|
||||
inspection = Inspection(
|
||||
public_ref=_next_public_ref(db),
|
||||
@@ -104,13 +207,8 @@ def register_vehicle_return(
|
||||
booking.status = "returned"
|
||||
booking.end_odometer_km = body.end_odometer_km
|
||||
|
||||
odometer_regression = body.end_odometer_km < vehicle.odometer_km
|
||||
quality_issue_ref: str | None = None
|
||||
canonical_odometer = vehicle.odometer_km
|
||||
if not odometer_regression:
|
||||
canonical_odometer = body.end_odometer_km
|
||||
vehicle.odometer_km = canonical_odometer
|
||||
else:
|
||||
if evaluation.odometer_regression:
|
||||
issue = DataQualityIssue(
|
||||
public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}",
|
||||
rule_type="odometer_regression",
|
||||
@@ -121,7 +219,7 @@ def register_vehicle_return(
|
||||
evidence_json={
|
||||
"summary": (
|
||||
f"Return submitted {body.end_odometer_km} km, below canonical "
|
||||
f"{vehicle.odometer_km} km."
|
||||
f"{evaluation.canonical_odometer_km} km."
|
||||
),
|
||||
"entity_ref": vehicle.public_ref,
|
||||
"related_refs": [booking.public_ref, inspection.public_ref],
|
||||
@@ -133,7 +231,8 @@ def register_vehicle_return(
|
||||
db.flush()
|
||||
quality_issue_ref = issue.public_ref
|
||||
|
||||
resulting_status = _derive_vehicle_status(body, vehicle, canonical_odometer)
|
||||
resulting_status = evaluation.resulting_vehicle_status
|
||||
vehicle.odometer_km = evaluation.resulting_odometer_km
|
||||
vehicle.operational_status = resulting_status
|
||||
vehicle.version += 1
|
||||
|
||||
@@ -164,14 +263,6 @@ def register_vehicle_return(
|
||||
},
|
||||
)
|
||||
|
||||
attention_reasons = []
|
||||
if body.damage_reported:
|
||||
attention_reasons.append("damage_reported")
|
||||
if body.technical_warning:
|
||||
attention_reasons.append("technical_warning")
|
||||
if odometer_regression:
|
||||
attention_reasons.append("odometer_regression")
|
||||
|
||||
event = OutboxEvent(
|
||||
event_id=uuid.uuid4(),
|
||||
event_type="vehicle.returned.v1",
|
||||
@@ -189,7 +280,7 @@ def register_vehicle_return(
|
||||
"vehicle_ref": vehicle.public_ref,
|
||||
"inspection_ref": inspection.public_ref,
|
||||
"resulting_vehicle_status": resulting_status,
|
||||
"attention_reasons": attention_reasons,
|
||||
"attention_reasons": evaluation.attention_reasons,
|
||||
},
|
||||
"aggregate_ref": booking.public_ref,
|
||||
},
|
||||
@@ -199,33 +290,15 @@ def register_vehicle_return(
|
||||
)
|
||||
db.add(event)
|
||||
|
||||
next_booking = db.scalar(
|
||||
select(Booking)
|
||||
.where(
|
||||
Booking.vehicle_id == vehicle.id,
|
||||
Booking.status == "reserved",
|
||||
Booking.starts_at > now,
|
||||
)
|
||||
.order_by(Booking.starts_at.asc())
|
||||
)
|
||||
next_booking_risk = None
|
||||
if next_booking is not None:
|
||||
hours_until = (next_booking.starts_at - now).total_seconds() / 3600
|
||||
next_booking_risk = {
|
||||
"booking_ref": next_booking.public_ref,
|
||||
"starts_at": next_booking.starts_at.isoformat(),
|
||||
"at_risk": resulting_status != "cleaning" or hours_until < 4,
|
||||
}
|
||||
|
||||
response_body = {
|
||||
"booking_ref": booking.public_ref,
|
||||
"vehicle_ref": vehicle.public_ref,
|
||||
"inspection_ref": inspection.public_ref,
|
||||
"resulting_vehicle_status": resulting_status,
|
||||
"odometer_regression": odometer_regression,
|
||||
"odometer_regression": evaluation.odometer_regression,
|
||||
"quality_issue_ref": quality_issue_ref,
|
||||
"workflow_event_id": str(event.event_id),
|
||||
"next_booking_risk": next_booking_risk,
|
||||
"next_booking_risk": evaluation.next_booking_risk,
|
||||
}
|
||||
|
||||
db.add(
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.booking import Booking
|
||||
from app.models.vehicle import Vehicle
|
||||
|
||||
|
||||
def test_demo_login_is_audited(ops_client):
|
||||
response = ops_client.get("/api/v1/audit", params={"action": "demo_login"})
|
||||
assert response.status_code == 200
|
||||
@@ -9,3 +16,59 @@ def test_demo_login_is_audited(ops_client):
|
||||
def test_audit_requires_authentication(client):
|
||||
response = client.get("/api/v1/audit")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_audit_requires_operations_manager(employee_client):
|
||||
response = employee_client.get("/api/v1/audit")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||
booking = db.scalar(
|
||||
select(Booking).where(Booking.vehicle_id == vehicle.id, Booking.status == "returned")
|
||||
)
|
||||
booking.status = "active"
|
||||
booking.start_odometer_km = start_odometer_km
|
||||
booking.end_odometer_km = None
|
||||
db.commit()
|
||||
return booking.public_ref
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_return_registered_audit_event_exposes_before_after_and_link(ops_client):
|
||||
booking_ref = _activate_booking("MO-015", start_odometer_km=17000)
|
||||
vehicle_before = ops_client.get("/api/v1/vehicles/MO-015").json()
|
||||
ops_client.post(
|
||||
f"/api/v1/bookings/{booking_ref}/return",
|
||||
json={
|
||||
"end_odometer_km": vehicle_before["odometer_km"] + 10,
|
||||
"fuel_level_percent": 50,
|
||||
"cleanliness_ok": True,
|
||||
"damage_reported": False,
|
||||
"technical_warning": False,
|
||||
},
|
||||
headers={"Idempotency-Key": "test-audit-before-after-001"},
|
||||
)
|
||||
|
||||
key = "test-audit-before-after-001"
|
||||
events = ops_client.get(
|
||||
"/api/v1/audit", params={"action": "return_registered"}
|
||||
).json()
|
||||
event = next(e for e in events if e["metadata"]["idempotency_key"] == key)
|
||||
assert event["before"] == {"status": "active"}
|
||||
assert event["after"]["status"] == "returned"
|
||||
assert event["entity_ref"] == booking_ref
|
||||
assert event["entity_link"] == f"/bookings/{booking_ref}"
|
||||
|
||||
vehicle_events = ops_client.get(
|
||||
"/api/v1/audit",
|
||||
params={"action": "vehicle_status_changed", "correlation_id": event["correlation_id"]},
|
||||
).json()
|
||||
assert len(vehicle_events) == 1
|
||||
assert vehicle_events[0]["entity_ref"] == "MO-015"
|
||||
assert vehicle_events[0]["entity_link"] == "/vehicles/MO-015"
|
||||
assert vehicle_events[0]["before"]["odometer_km"] == vehicle_before["odometer_km"]
|
||||
|
||||
@@ -17,4 +17,51 @@ def test_rental_employee_cannot_reset_demo(employee_client):
|
||||
def test_operations_manager_can_reset_demo(ops_client):
|
||||
response = ops_client.post("/api/v1/demo/reset")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["counts"]["vehicles"] == 50
|
||||
body = response.json()
|
||||
assert body["counts"]["vehicles"] == 50
|
||||
assert body["anchor_date"]
|
||||
assert body["seeded_at"]
|
||||
assert body["scenario_integrity"]["all_ready"] is True
|
||||
assert body["scenario_integrity"]["not_ready"] == []
|
||||
|
||||
|
||||
def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch):
|
||||
import app.api.routers.demo as demo_router
|
||||
|
||||
monkeypatch.setattr(demo_router.settings, "demo_allow_reset", False)
|
||||
response = ops_client.post("/api/v1/demo/reset")
|
||||
assert response.status_code == 403
|
||||
|
||||
# Restore real demo data: this test intentionally disabled reset, so a following test
|
||||
# module must not inherit a database left mid-mutation by an earlier test.
|
||||
monkeypatch.setattr(demo_router.settings, "demo_allow_reset", True)
|
||||
assert ops_client.post("/api/v1/demo/reset").status_code == 200
|
||||
|
||||
|
||||
def test_session_endpoint_requires_authentication(client):
|
||||
response = client.get("/api/v1/demo/session")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_session_endpoint_confirms_logged_in_user(ops_client):
|
||||
response = ops_client.get("/api/v1/demo/session")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["role"] == "operations_manager"
|
||||
assert body["public_ref"] == "USR-OPS"
|
||||
|
||||
|
||||
def test_logout_invalidates_session(ops_client):
|
||||
confirmed = ops_client.get("/api/v1/demo/session")
|
||||
assert confirmed.status_code == 200
|
||||
|
||||
logout = ops_client.post("/api/v1/demo/logout")
|
||||
assert logout.status_code == 200
|
||||
|
||||
after = ops_client.get("/api/v1/demo/session")
|
||||
assert after.status_code == 401
|
||||
|
||||
|
||||
def test_logout_without_a_session_is_safe(client):
|
||||
response = client.post("/api/v1/demo/logout")
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.booking import Booking
|
||||
from app.models.vehicle import Vehicle
|
||||
|
||||
|
||||
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||
booking = db.scalar(
|
||||
select(Booking).where(Booking.vehicle_id == vehicle.id, Booking.status == "returned")
|
||||
)
|
||||
booking.status = "active"
|
||||
booking.start_odometer_km = start_odometer_km
|
||||
booking.end_odometer_km = None
|
||||
db.commit()
|
||||
return booking.public_ref
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_list_includes_all_five_rule_types(ops_client):
|
||||
response = ops_client.get("/api/v1/data-quality/issues")
|
||||
assert response.status_code == 200
|
||||
@@ -25,6 +48,26 @@ def test_scan_requires_operations_manager(employee_client):
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_list_issues_requires_operations_manager(employee_client):
|
||||
response = employee_client.get("/api/v1/data-quality/issues")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_get_issue_requires_operations_manager(employee_client):
|
||||
response = employee_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_defer_requires_operations_manager(employee_client):
|
||||
response = employee_client.post("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/defer")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_reject_requires_operations_manager(employee_client):
|
||||
response = employee_client.post("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/reject")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_s2_duplicate_customer_issue_detail(ops_client):
|
||||
response = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
|
||||
assert response.status_code == 200
|
||||
@@ -104,3 +147,242 @@ def test_merge_customers_s2_scenario_rewires_and_audits(ops_client):
|
||||
json={"survivor_ref": "CUS-0012"},
|
||||
)
|
||||
assert replay.status_code == 409
|
||||
|
||||
|
||||
def test_overlap_related_snapshots_are_typed_as_bookings_not_vehicles(ops_client):
|
||||
body = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP").json()
|
||||
assert len(body["related_snapshots"]) == 2
|
||||
for snap in body["related_snapshots"]:
|
||||
assert snap["entity_type"] == "booking"
|
||||
assert snap["public_ref"] in {"BK-DEMO-OVERLAP-A", "BK-DEMO-OVERLAP-B"}
|
||||
assert "starts_at" in snap and "ends_at" in snap
|
||||
|
||||
|
||||
def _first_open(ops_client, rule_type: str) -> dict:
|
||||
issues = ops_client.get(
|
||||
"/api/v1/data-quality/issues", params={"rule_type": rule_type, "status": "open"}
|
||||
).json()
|
||||
assert issues, f"expected at least one open {rule_type} issue"
|
||||
return issues[0]
|
||||
|
||||
|
||||
def test_provide_fields_requires_operations_manager(employee_client):
|
||||
response = employee_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-DEMO-ATTENTION/provide-fields",
|
||||
json={"fields": {"registration_number": "TST-001"}},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_provide_fields_rejects_wrong_rule_type(ops_client):
|
||||
response = ops_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/provide-fields",
|
||||
json={"fields": {"make": "Test"}},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["error"]["code"] == "NOT_A_MISSING_FIELD_ISSUE"
|
||||
|
||||
|
||||
def test_provide_fields_rejects_disallowed_field(ops_client):
|
||||
target = _first_open(ops_client, "missing_required_field")
|
||||
detail = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
|
||||
disallowed = "city" if detail["entity_type"] == "customer" else "next_service_km"
|
||||
response = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{target['public_ref']}/provide-fields",
|
||||
json={"fields": {disallowed: "anything"}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert response.json()["error"]["code"] == "INVALID_FIELD"
|
||||
|
||||
|
||||
def test_provide_fields_resolves_a_vehicle_missing_field_issue(ops_client):
|
||||
issues = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"rule_type": "missing_required_field", "status": "open"},
|
||||
).json()
|
||||
target = next(i for i in issues if i["entity_type"] == "vehicle")
|
||||
|
||||
response = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{target['public_ref']}/provide-fields",
|
||||
json={
|
||||
"fields": {
|
||||
"registration_number": "TST-999",
|
||||
"make": "TestMake",
|
||||
"model": "TestModel",
|
||||
"location": "Depot",
|
||||
}
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "resolved"
|
||||
|
||||
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||
assert vehicle["registration_number"] == "TST-999"
|
||||
|
||||
|
||||
def test_resolve_overlap_requires_operations_manager(employee_client):
|
||||
response = employee_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
|
||||
json={"booking_ref": "BK-DEMO-OVERLAP-A"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_resolve_overlap_rejects_unrelated_booking(ops_client):
|
||||
response = ops_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
|
||||
json={"booking_ref": "BK-DEMO-RETURN"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert response.json()["error"]["code"] == "INVALID_BOOKING_REFERENCE"
|
||||
|
||||
|
||||
def test_resolve_overlap_blocks_one_booking_and_resolves(ops_client):
|
||||
response = ops_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
|
||||
json={"booking_ref": "BK-DEMO-OVERLAP-A", "note": "Blocked the later commitment."},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "resolved"
|
||||
|
||||
booking = ops_client.get("/api/v1/bookings/BK-DEMO-OVERLAP-A").json()
|
||||
assert booking["status"] == "blocked"
|
||||
|
||||
|
||||
def test_apply_recommended_status_requires_operations_manager(employee_client):
|
||||
response = employee_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-DEMO-STATUS/apply-recommended-status"
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_apply_recommended_status_resolves_conflict(ops_client):
|
||||
target = _first_open(ops_client, "vehicle_status_conflict")
|
||||
response = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{target['public_ref']}/apply-recommended-status"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["issue"]["status"] == "resolved"
|
||||
assert body["applied_status"]
|
||||
assert body["reason"]
|
||||
|
||||
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||
assert vehicle["operational_status"] == body["applied_status"]
|
||||
|
||||
|
||||
def test_resolve_odometer_regression_requires_operations_manager(employee_client):
|
||||
response = employee_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-0007/resolve-odometer-regression",
|
||||
json={"decision": "retain_canonical"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_resolve_odometer_regression_correction_below_canonical_is_rejected_then_retained(
|
||||
ops_client,
|
||||
):
|
||||
target = _first_open(ops_client, "odometer_regression")
|
||||
vehicle_before = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||
|
||||
too_low = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{target['public_ref']}/resolve-odometer-regression",
|
||||
json={
|
||||
"decision": "correct_reading",
|
||||
"booking_ref": "BK-DEMO-RETURN",
|
||||
"corrected_odometer_km": max(vehicle_before["odometer_km"] - 100, 0),
|
||||
},
|
||||
)
|
||||
assert too_low.status_code == 422
|
||||
assert too_low.json()["error"]["code"] in (
|
||||
"CORRECTION_BELOW_CANONICAL",
|
||||
"INVALID_BOOKING_REFERENCE",
|
||||
)
|
||||
|
||||
# The rejected attempt must not have resolved or mutated anything.
|
||||
still_open = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
|
||||
assert still_open["status"] == "open"
|
||||
|
||||
retained = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{target['public_ref']}/resolve-odometer-regression",
|
||||
json={"decision": "retain_canonical", "note": "Submitted reading treated as erroneous."},
|
||||
)
|
||||
assert retained.status_code == 200
|
||||
assert retained.json()["status"] == "resolved"
|
||||
|
||||
vehicle_after = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||
assert vehicle_after["odometer_km"] == vehicle_before["odometer_km"]
|
||||
|
||||
|
||||
def test_resolve_odometer_regression_correct_reading_updates_canonical(ops_client):
|
||||
# The seeded odometer_regression issues carry no related booking (CSV-only rows).
|
||||
# Create a fresh one with a real related booking via a live regression return, so
|
||||
# the "correct_reading" path has an actual booking_ref to target.
|
||||
booking_ref = _activate_booking("MO-018", start_odometer_km=12000)
|
||||
vehicle_before = ops_client.get("/api/v1/vehicles/MO-018").json()
|
||||
low_reading = vehicle_before["odometer_km"] - 200
|
||||
returned = ops_client.post(
|
||||
f"/api/v1/bookings/{booking_ref}/return",
|
||||
json={
|
||||
"end_odometer_km": low_reading,
|
||||
"fuel_level_percent": 50,
|
||||
"cleanliness_ok": True,
|
||||
"damage_reported": False,
|
||||
"technical_warning": False,
|
||||
},
|
||||
headers={"Idempotency-Key": "test-dq-odometer-correct-001"},
|
||||
)
|
||||
assert returned.status_code == 201
|
||||
issue_ref = returned.json()["quality_issue_ref"]
|
||||
assert issue_ref is not None
|
||||
|
||||
corrected = vehicle_before["odometer_km"] + 500
|
||||
response = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{issue_ref}/resolve-odometer-regression",
|
||||
json={
|
||||
"decision": "correct_reading",
|
||||
"booking_ref": booking_ref,
|
||||
"corrected_odometer_km": corrected,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "resolved"
|
||||
|
||||
vehicle = ops_client.get("/api/v1/vehicles/MO-018").json()
|
||||
assert vehicle["odometer_km"] == corrected
|
||||
booking = ops_client.get(f"/api/v1/bookings/{booking_ref}").json()
|
||||
assert booking["end_odometer_km"] == corrected
|
||||
|
||||
|
||||
def test_manual_scan_records_audit_event(ops_client):
|
||||
scan = ops_client.post("/api/v1/data-quality/scan")
|
||||
assert scan.status_code == 200
|
||||
|
||||
events = ops_client.get(
|
||||
"/api/v1/audit", params={"action": "data_quality_scan_run"}
|
||||
).json()
|
||||
assert len(events) >= 1
|
||||
assert "created" in events[0]["metadata"]
|
||||
|
||||
|
||||
def test_rejected_issue_recurrence_links_to_prior_decision(ops_client):
|
||||
# Reject an open vehicle_status_conflict issue without changing the vehicle, so the
|
||||
# next scan re-detects the same unresolved condition -- it must not silently vanish
|
||||
# or reopen the old row, but the new issue should stay linked to the rejection.
|
||||
target = _first_open(ops_client, "vehicle_status_conflict")
|
||||
rejected = ops_client.post(f"/api/v1/data-quality/issues/{target['public_ref']}/reject")
|
||||
assert rejected.status_code == 200
|
||||
|
||||
rescan = ops_client.post("/api/v1/data-quality/scan")
|
||||
assert rescan.status_code == 200
|
||||
assert rescan.json()["created"].get("vehicle_status_conflict", 0) >= 1
|
||||
|
||||
reopened = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"rule_type": "vehicle_status_conflict", "status": "open"},
|
||||
).json()
|
||||
match = next(
|
||||
(i for i in reopened if i["evidence"].get("reopened_from") == target["public_ref"]), None
|
||||
)
|
||||
assert match is not None, "expected a new issue linked back to the rejected one"
|
||||
assert match["evidence"]["previous_decision"] == "rejected"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
from app.core.db import SessionLocal
|
||||
from app.seed_loader import reset_and_seed
|
||||
|
||||
|
||||
def test_demo_manifest_is_public(client):
|
||||
# No login call at all -- the demo-entry screen and badge need this before any
|
||||
# session exists.
|
||||
response = client.get("/api/v1/demo/manifest")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_demo_manifest_shape(client):
|
||||
body = client.get("/api/v1/demo/manifest").json()
|
||||
assert body["organization_name"] == "Northstar Mobility"
|
||||
assert body["demo_mode"] is True
|
||||
assert body["synthetic_data"] is True
|
||||
assert body["allow_reset"] is True
|
||||
assert body["timezone"] == "Europe/Brussels"
|
||||
assert body["guide_available"] is True
|
||||
assert set(body["required_roles"]) == {"operations_manager", "rental_employee"}
|
||||
assert body["last_reset_at"] is not None
|
||||
assert body["anchor_date"] is not None
|
||||
|
||||
scenario_ids = {s["id"] for s in body["scenarios"]}
|
||||
assert scenario_ids == {
|
||||
"return-anomaly",
|
||||
"duplicate-customer",
|
||||
"booking-overlap",
|
||||
"automation-retry",
|
||||
"knowledge-question",
|
||||
}
|
||||
integration_keys = {i["key"] for i in body["integrations"]}
|
||||
assert integration_keys == {"n8n", "ragcore", "mcp_hub"}
|
||||
|
||||
|
||||
def test_demo_manifest_scenarios_ready_after_fresh_reset(client):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
body = client.get("/api/v1/demo/manifest").json()
|
||||
scenarios = {s["id"]: s for s in body["scenarios"]}
|
||||
for scenario_id, scenario in scenarios.items():
|
||||
assert scenario["ready"] is True, f"{scenario_id} should be ready right after a reset"
|
||||
assert scenario["blocked_reason_code"] is None
|
||||
assert scenario["start_path"]
|
||||
|
||||
|
||||
def test_demo_manifest_ragcore_labelled_as_demo_mode_not_live(client):
|
||||
body = client.get("/api/v1/demo/manifest").json()
|
||||
ragcore = next(i for i in body["integrations"] if i["key"] == "ragcore")
|
||||
assert ragcore["status_code"] == "demoMode"
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -161,6 +161,83 @@ def test_deliver_one_handles_malformed_payload_without_getting_stuck(monkeypatch
|
||||
assert "Malformed outbox payload" in event.last_error
|
||||
|
||||
|
||||
def test_claim_sets_a_lease_deadline():
|
||||
event_id = _make_pending_event("MO-006")
|
||||
settings = get_settings()
|
||||
before = datetime.now(UTC)
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
event = _get_event(event_id)
|
||||
assert event.delivery_status == "delivering"
|
||||
assert event.next_attempt_at is not None
|
||||
lease = settings.n8n_delivery_lease_seconds
|
||||
assert event.next_attempt_at > before + timedelta(seconds=lease - 5)
|
||||
|
||||
|
||||
def test_reclaim_ignores_an_active_unexpired_lease():
|
||||
# A worker that is still within its lease window must not be disturbed -- this is
|
||||
# what prevents double delivery of an event another (still-alive) worker is handling.
|
||||
event_id = _make_pending_event("MO-007")
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
reclaimed = dispatcher._reclaim_stale_deliveries()
|
||||
assert reclaimed == 0
|
||||
assert _get_event(event_id).delivery_status == "delivering"
|
||||
|
||||
|
||||
def test_reclaim_recovers_an_expired_lease_and_preserves_attempts(monkeypatch):
|
||||
# Simulates a process crash: the row was claimed (delivering) but no outcome was ever
|
||||
# recorded, and its lease has since expired.
|
||||
event_id = _make_pending_event("MO-008")
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id))
|
||||
event.attempts = 2
|
||||
event.next_attempt_at = datetime.now(UTC) - timedelta(seconds=1)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
reclaimed = dispatcher._reclaim_stale_deliveries()
|
||||
assert reclaimed == 1
|
||||
|
||||
event = _get_event(event_id)
|
||||
assert event.delivery_status == "pending"
|
||||
assert event.next_attempt_at is None
|
||||
assert event.attempts == 2
|
||||
assert "stale" in event.last_error.lower()
|
||||
|
||||
# The reclaimed event is now a normal pending event, immediately claimable again.
|
||||
claimed = dispatcher._claim_due_events()
|
||||
assert event_id in claimed
|
||||
|
||||
|
||||
def test_run_dispatch_cycle_recovers_a_stale_lease_before_claiming(monkeypatch):
|
||||
event_id = _make_pending_event("MO-009")
|
||||
dispatcher._claim_due_events()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id))
|
||||
event.next_attempt_at = datetime.now(UTC) - timedelta(seconds=1)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def fake_post(url, json, timeout):
|
||||
return SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
processed = dispatcher.run_dispatch_cycle()
|
||||
|
||||
assert processed >= 1
|
||||
assert _get_event(event_id).delivery_status == "succeeded"
|
||||
|
||||
|
||||
def test_run_dispatch_cycle_end_to_end(monkeypatch):
|
||||
event_id = _make_pending_event("MO-005")
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
def test_integration_status_requires_operations_manager(employee_client):
|
||||
response = employee_client.get("/api/v1/integrations/status")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_integration_status_requires_authentication(client):
|
||||
response = client.get("/api/v1/integrations/status")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_integration_status_reflects_seeded_mixed_outcomes(ops_client):
|
||||
response = ops_client.get("/api/v1/integrations/status")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
|
||||
n8n = body["n8n"]
|
||||
assert n8n["dispatch_enabled"] is True
|
||||
assert n8n["succeeded"] >= 1
|
||||
assert n8n["failed"] >= 1
|
||||
# The seed deliberately carries both failed and succeeded events, so a single most-
|
||||
# recent-event read would misreport health -- the aggregate must call this "degraded",
|
||||
# not "operational" or "unavailable".
|
||||
assert n8n["state"] == "degraded"
|
||||
assert n8n["latest_success_at"] is not None
|
||||
assert n8n["latest_failure_at"] is not None
|
||||
|
||||
mcp_hub = body["mcp_hub"]
|
||||
assert mcp_hub["registration_enabled"] is False
|
||||
assert mcp_hub["state"] == "not_configured"
|
||||
|
||||
|
||||
def test_integration_status_is_operational_once_all_failed_events_resolved(ops_client):
|
||||
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
||||
for run in failed:
|
||||
retried = ops_client.post(f"/api/v1/workflows/{run['event_id']}/retry")
|
||||
assert retried.status_code == 200
|
||||
|
||||
response = ops_client.get("/api/v1/integrations/status")
|
||||
body = response.json()["n8n"]
|
||||
assert body["failed"] == 0
|
||||
assert body["state"] == "operational"
|
||||
@@ -82,3 +82,43 @@ def test_callback_is_idempotent_by_event_id(client, ops_client):
|
||||
).json()
|
||||
matching = [e for e in audit_events if e["metadata"]["event_id"] == event_id]
|
||||
assert len(matching) == 1
|
||||
|
||||
|
||||
def test_scheduled_scan_rejects_wrong_service_token(client):
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/scheduled-scan",
|
||||
headers={"X-Service-Token": "wrong-token"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_scheduled_scan_requires_service_token_header(client):
|
||||
response = client.post("/api/v1/integrations/n8n/scheduled-scan")
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_scheduled_scan_runs_and_returns_counts_by_rule(client, ops_client):
|
||||
settings = get_settings()
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/scheduled-scan",
|
||||
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"created": {}} # already-seeded conditions, nothing new
|
||||
|
||||
audit_events = ops_client.get(
|
||||
"/api/v1/audit", params={"action": "data_quality_scan_run"}
|
||||
).json()
|
||||
service_triggered = [e for e in audit_events if e["actor_type"] == "service"]
|
||||
assert len(service_triggered) >= 1
|
||||
assert service_triggered[0]["actor_label"] == "n8n scheduled scan"
|
||||
|
||||
|
||||
def test_scheduled_scan_is_idempotent_across_repeated_triggers(client):
|
||||
settings = get_settings()
|
||||
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||
first = client.post("/api/v1/integrations/n8n/scheduled-scan", headers=headers)
|
||||
second = client.post("/api/v1/integrations/n8n/scheduled-scan", headers=headers)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert second.json()["created"] == {}
|
||||
|
||||
@@ -32,7 +32,52 @@ def test_demo_provider_health_reports_document_count():
|
||||
health = provider.health()
|
||||
assert health.provider == "demo"
|
||||
assert health.available is True
|
||||
assert health.document_count == 10
|
||||
assert health.document_count == 11
|
||||
|
||||
|
||||
def test_demo_provider_health_reports_document_count_per_language():
|
||||
provider = DemoKnowledgeProvider()
|
||||
for language in ("nl-BE", "en-GB", "fr-BE"):
|
||||
assert provider.health(language).document_count == 11
|
||||
|
||||
|
||||
def test_demo_provider_grounds_damage_question_in_dutch():
|
||||
provider = DemoKnowledgeProvider()
|
||||
answer = provider.ask(
|
||||
"Wat moet ik doen als een voertuig terugkomt met schade?",
|
||||
"test-correlation-nl",
|
||||
"nl-BE",
|
||||
)
|
||||
assert answer.evidence_state == "grounded"
|
||||
document_ids = {s.document_id for s in answer.sources}
|
||||
assert "damage-procedure" in document_ids
|
||||
|
||||
|
||||
def test_demo_provider_grounds_damage_question_in_french():
|
||||
provider = DemoKnowledgeProvider()
|
||||
answer = provider.ask(
|
||||
"Que dois-je faire quand un véhicule revient avec des dommages ?",
|
||||
"test-correlation-fr",
|
||||
"fr-BE",
|
||||
)
|
||||
assert answer.evidence_state == "grounded"
|
||||
document_ids = {s.document_id for s in answer.sources}
|
||||
assert "damage-procedure" in document_ids
|
||||
|
||||
|
||||
def test_demo_provider_insufficient_evidence_message_is_localized():
|
||||
provider = DemoKnowledgeProvider()
|
||||
nl_answer = provider.ask(
|
||||
"Wat is de hoofdstad van Frankrijk?", "test-correlation-nl-2", "nl-BE"
|
||||
)
|
||||
fr_answer = provider.ask(
|
||||
"Quelle est la capitale de la France ?", "test-correlation-fr-2", "fr-BE"
|
||||
)
|
||||
assert nl_answer.evidence_state == "insufficient"
|
||||
assert fr_answer.evidence_state == "insufficient"
|
||||
assert nl_answer.answer != fr_answer.answer
|
||||
assert "France" not in nl_answer.answer
|
||||
assert "France" not in fr_answer.answer
|
||||
|
||||
|
||||
def test_ask_question_endpoint_grounded(ops_client):
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.main import app
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.models.vehicle import Vehicle
|
||||
|
||||
|
||||
@@ -39,6 +44,148 @@ def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
||||
db.close()
|
||||
|
||||
|
||||
def _set_next_service_km(vehicle_ref: str, threshold: int) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||
vehicle.next_service_km = threshold
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _add_reserved_booking(vehicle_ref: str, *, hours_from_now: float) -> str:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||
customer = db.scalar(select(Customer))
|
||||
starts_at = datetime.now(UTC) + timedelta(hours=hours_from_now)
|
||||
public_ref = f"BK-TEST-{uuid.uuid4().hex[:8].upper()}"
|
||||
booking = Booking(
|
||||
public_ref=public_ref,
|
||||
customer_id=customer.id,
|
||||
vehicle_id=vehicle.id,
|
||||
starts_at=starts_at,
|
||||
ends_at=starts_at + timedelta(days=2),
|
||||
status="reserved",
|
||||
requirements_complete=True,
|
||||
)
|
||||
db.add(booking)
|
||||
db.commit()
|
||||
return public_ref
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _counts() -> tuple[int, int]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return (
|
||||
len(db.scalars(select(AuditEvent)).all()),
|
||||
len(db.scalars(select(OutboxEvent)).all()),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_preview_performs_no_writes_and_matches_commit(ops_client):
|
||||
booking_ref = _activate_booking("MO-006", start_odometer_km=30000)
|
||||
vehicle_before = ops_client.get("/api/v1/vehicles/MO-006").json()
|
||||
new_reading = vehicle_before["odometer_km"] + 25
|
||||
body = _return_body(end_odometer_km=new_reading)
|
||||
|
||||
audit_before, outbox_before = _counts()
|
||||
preview = ops_client.post(f"/api/v1/bookings/{booking_ref}/return-preview", json=body)
|
||||
assert preview.status_code == 200
|
||||
preview_body = preview.json()
|
||||
audit_after, outbox_after = _counts()
|
||||
assert (audit_after, outbox_after) == (audit_before, outbox_before)
|
||||
|
||||
booking_mid = ops_client.get(f"/api/v1/bookings/{booking_ref}").json()
|
||||
assert booking_mid["status"] == "active" # preview did not mutate the booking
|
||||
vehicle_mid = ops_client.get("/api/v1/vehicles/MO-006").json()
|
||||
assert vehicle_mid["odometer_km"] == vehicle_before["odometer_km"]
|
||||
|
||||
assert preview_body["odometer_regression"] is False
|
||||
assert preview_body["resulting_odometer_km"] == new_reading
|
||||
assert preview_body["canonical_odometer_km"] == vehicle_before["odometer_km"]
|
||||
|
||||
commit = ops_client.post(
|
||||
f"/api/v1/bookings/{booking_ref}/return",
|
||||
json=body,
|
||||
headers={"Idempotency-Key": "test-preview-matches-commit-001"},
|
||||
)
|
||||
assert commit.status_code == 201
|
||||
commit_body = commit.json()
|
||||
assert commit_body["resulting_vehicle_status"] == preview_body["resulting_vehicle_status"]
|
||||
assert commit_body["odometer_regression"] == preview_body["odometer_regression"]
|
||||
assert commit_body["next_booking_risk"] == preview_body["next_booking_risk"]
|
||||
|
||||
|
||||
def test_preview_detects_odometer_regression(ops_client):
|
||||
booking_ref = _activate_booking("MO-007", start_odometer_km=15000)
|
||||
vehicle_before = ops_client.get("/api/v1/vehicles/MO-007").json()
|
||||
low_reading = vehicle_before["odometer_km"] - 100
|
||||
|
||||
preview = ops_client.post(
|
||||
f"/api/v1/bookings/{booking_ref}/return-preview",
|
||||
json=_return_body(end_odometer_km=low_reading),
|
||||
)
|
||||
assert preview.status_code == 200
|
||||
body = preview.json()
|
||||
assert body["odometer_regression"] is True
|
||||
assert body["would_create_quality_issue"] is True
|
||||
assert "odometer_regression" in body["attention_reasons"]
|
||||
assert body["resulting_odometer_km"] == vehicle_before["odometer_km"]
|
||||
|
||||
vehicle_after = ops_client.get("/api/v1/vehicles/MO-007").json()
|
||||
assert vehicle_after["odometer_km"] == vehicle_before["odometer_km"]
|
||||
|
||||
|
||||
def test_preview_detects_service_due(ops_client):
|
||||
booking_ref = _activate_booking("MO-009", start_odometer_km=18000)
|
||||
vehicle_before = ops_client.get("/api/v1/vehicles/MO-009").json()
|
||||
_set_next_service_km("MO-009", vehicle_before["odometer_km"] + 50)
|
||||
|
||||
preview = ops_client.post(
|
||||
f"/api/v1/bookings/{booking_ref}/return-preview",
|
||||
json=_return_body(end_odometer_km=vehicle_before["odometer_km"] + 100),
|
||||
)
|
||||
assert preview.status_code == 200
|
||||
body = preview.json()
|
||||
assert body["resulting_vehicle_status"] == "maintenance"
|
||||
assert "service threshold" in body["status_reason"]
|
||||
|
||||
|
||||
def test_preview_detects_next_booking_risk(ops_client):
|
||||
booking_ref = _activate_booking("MO-011", start_odometer_km=19000)
|
||||
_add_reserved_booking("MO-011", hours_from_now=2)
|
||||
|
||||
preview = ops_client.post(
|
||||
f"/api/v1/bookings/{booking_ref}/return-preview",
|
||||
json=_return_body(end_odometer_km=19500),
|
||||
)
|
||||
assert preview.status_code == 200
|
||||
risk = preview.json()["next_booking_risk"]
|
||||
assert risk is not None
|
||||
assert risk["at_risk"] is True # less than 4 hours away
|
||||
|
||||
|
||||
def test_preview_requires_active_booking(ops_client):
|
||||
booking_ref = _activate_booking("MO-014", start_odometer_km=21000)
|
||||
ops_client.post(
|
||||
f"/api/v1/bookings/{booking_ref}/return",
|
||||
json=_return_body(end_odometer_km=21500),
|
||||
headers={"Idempotency-Key": "test-preview-requires-active-001"},
|
||||
)
|
||||
preview = ops_client.post(
|
||||
f"/api/v1/bookings/{booking_ref}/return-preview",
|
||||
json=_return_body(end_odometer_km=22000),
|
||||
)
|
||||
assert preview.status_code == 409
|
||||
assert preview.json()["error"]["code"] == "INVALID_BOOKING_STATE"
|
||||
|
||||
|
||||
def test_register_return_success_updates_canonical_odometer(ops_client):
|
||||
booking_ref = _activate_booking("MO-003", start_odometer_km=20000)
|
||||
vehicle_before = ops_client.get("/api/v1/vehicles/MO-003").json()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
def test_search_requires_authentication(client):
|
||||
response = client.get("/api/v1/search", params={"q": "MO-001"})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_search_finds_a_vehicle_by_reference(ops_client):
|
||||
response = ops_client.get("/api/v1/search", params={"q": "MO-001"})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
match = next((r for r in body["results"] if r["type"] == "vehicle"), None)
|
||||
assert match is not None
|
||||
assert match["label"] == "MO-001"
|
||||
assert match["link"] == "/vehicles/MO-001"
|
||||
|
||||
|
||||
def test_search_finds_a_booking_by_reference(ops_client):
|
||||
response = ops_client.get("/api/v1/search", params={"q": "BK-DEMO-RETURN"})
|
||||
assert response.status_code == 200
|
||||
match = next((r for r in response.json()["results"] if r["type"] == "booking"), None)
|
||||
assert match is not None
|
||||
assert match["link"] == "/bookings/BK-DEMO-RETURN"
|
||||
|
||||
|
||||
def test_search_finds_a_data_quality_issue_for_operations_manager(ops_client):
|
||||
response = ops_client.get("/api/v1/search", params={"q": "DQ-DEMO-OVERLAP"})
|
||||
assert response.status_code == 200
|
||||
match = next((r for r in response.json()["results"] if r["type"] == "data_quality_issue"), None)
|
||||
assert match is not None
|
||||
assert match["link"] == "/data-quality/DQ-DEMO-OVERLAP"
|
||||
|
||||
|
||||
def test_search_never_returns_data_quality_issues_for_rental_employee(employee_client):
|
||||
response = employee_client.get("/api/v1/search", params={"q": "DQ-DEMO-OVERLAP"})
|
||||
assert response.status_code == 200
|
||||
assert all(r["type"] != "data_quality_issue" for r in response.json()["results"])
|
||||
|
||||
|
||||
def test_search_section_result_visible_to_operations_manager(ops_client):
|
||||
result = ops_client.get("/api/v1/search", params={"q": "audit"}).json()
|
||||
assert any(r["type"] == "section" and r["link"] == "/audit" for r in result["results"])
|
||||
|
||||
|
||||
def test_search_section_result_hidden_from_rental_employee(employee_client):
|
||||
result = employee_client.get("/api/v1/search", params={"q": "audit"}).json()
|
||||
assert all(r["link"] != "/audit" for r in result["results"])
|
||||
|
||||
|
||||
def test_search_never_returns_customer_results(ops_client):
|
||||
response = ops_client.get("/api/v1/search", params={"q": "CUS-0012"})
|
||||
assert response.status_code == 200
|
||||
assert all(r["type"] != "customer" for r in response.json()["results"])
|
||||
|
||||
|
||||
def test_search_no_match_returns_empty_results(ops_client):
|
||||
response = ops_client.get("/api/v1/search", params={"q": "zzz-no-such-thing-zzz"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["results"] == []
|
||||
@@ -1,13 +1,16 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.models.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.seed_loader import reset_and_seed
|
||||
from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed
|
||||
|
||||
|
||||
def test_seed_counts_match_deterministic_dataset():
|
||||
@@ -52,3 +55,117 @@ def test_seed_demo_scenarios_present():
|
||||
assert failed_run is not None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _by_ref(db, model, ref):
|
||||
return db.scalar(select(model).where(model.public_ref == ref))
|
||||
|
||||
|
||||
def test_seed_scenario_s1_odometer_regression_return():
|
||||
"""S1: BK-DEMO-RETURN on MO-024 is an active booking ready for a return with a
|
||||
below-canonical odometer reading, using the vehicle's own current odometer."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
booking = _by_ref(db, Booking, "BK-DEMO-RETURN")
|
||||
vehicle = _by_ref(db, Vehicle, "MO-024")
|
||||
assert booking is not None and vehicle is not None
|
||||
assert booking.vehicle_id == vehicle.id
|
||||
assert booking.status == "active"
|
||||
assert booking.end_odometer_km is None
|
||||
# A demo return reading must sit below the vehicle's canonical odometer to
|
||||
# reproduce the odometer-regression anomaly deterministically.
|
||||
assert vehicle.odometer_km > 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_scenario_s2_duplicate_customer_pair():
|
||||
"""S2: CUS-0012/CUS-0178 form a possible-duplicate pair with a matching open issue."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
primary = _by_ref(db, Customer, "CUS-0012")
|
||||
duplicate = _by_ref(db, Customer, "CUS-0178")
|
||||
assert primary is not None and duplicate is not None
|
||||
assert primary.email == duplicate.email
|
||||
assert duplicate.merged_into_customer_id is None
|
||||
|
||||
issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-DUPLICATE")
|
||||
assert issue is not None
|
||||
assert issue.rule_type == "possible_duplicate_customer"
|
||||
assert issue.status == "open"
|
||||
related = issue.evidence_json.get("related_refs", [])
|
||||
assert "CUS-0012" in related or "CUS-0178" in related
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_scenario_s4_booking_overlap():
|
||||
"""S4: MO-016 carries two overlapping reservations plus a matching open issue."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
vehicle = _by_ref(db, Vehicle, "MO-016")
|
||||
booking_a = _by_ref(db, Booking, "BK-DEMO-OVERLAP-A")
|
||||
booking_b = _by_ref(db, Booking, "BK-DEMO-OVERLAP-B")
|
||||
assert vehicle is not None and booking_a is not None and booking_b is not None
|
||||
assert booking_a.vehicle_id == vehicle.id
|
||||
assert booking_b.vehicle_id == vehicle.id
|
||||
assert booking_a.starts_at < booking_b.ends_at
|
||||
assert booking_b.starts_at < booking_a.ends_at
|
||||
|
||||
issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-OVERLAP")
|
||||
assert issue is not None
|
||||
assert issue.rule_type == "booking_overlap"
|
||||
assert issue.status == "open"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_scenario_s5_failed_workflow_run():
|
||||
"""S5: one seeded outbox event is durably 'failed' (terminal, retryable), not merely
|
||||
pending, so the background dispatcher never silently auto-heals it away."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
failed = db.scalar(
|
||||
select(OutboxEvent).where(
|
||||
OutboxEvent.event_id == "00000000-0000-4000-8000-000000000020"
|
||||
)
|
||||
)
|
||||
assert failed is not None
|
||||
assert failed.delivery_status == "failed"
|
||||
assert failed.attempts >= 1
|
||||
assert failed.last_error
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_dates_are_anchored_to_reset_moment():
|
||||
"""Every reset shifts seeded dates by (real today - authored anchor), so scenario
|
||||
bookings stay 'today'/'near-future' relative to whenever the reset actually ran,
|
||||
instead of decaying back to the fixed 2026-08-01 authoring date."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = reset_and_seed(db)
|
||||
today = datetime.now(UTC).date()
|
||||
assert result.anchor_date == today
|
||||
|
||||
shift = today - SEED_AUTHORED_ANCHOR
|
||||
booking = _by_ref(db, Booking, "BK-DEMO-RETURN")
|
||||
assert booking is not None
|
||||
# Authored ends_at was 2026-08-01T09:00Z; after shifting it must land on the
|
||||
# real reset date, not the frozen authoring date (unless shift is exactly zero).
|
||||
assert booking.ends_at.date() == today or shift.days == 0
|
||||
|
||||
marker = db.scalar(
|
||||
select(AuditEvent)
|
||||
.where(AuditEvent.action == "demo_data_seeded")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
)
|
||||
assert marker is not None
|
||||
assert marker.metadata_json["anchor_date"] == today.isoformat()
|
||||
assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -24,7 +24,6 @@ services:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops}
|
||||
TZ: ${TZ:-Europe/Brussels}
|
||||
APP_SECRET: ${APP_SECRET:-replace-in-production}
|
||||
DEMO_TODAY: ${DEMO_TODAY:-2026-08-01}
|
||||
CORS_ALLOW_ORIGINS: ${MOBILITYOPS_PUBLIC_URL:-http://localhost:1228}
|
||||
KNOWLEDGE_PROVIDER: ${KNOWLEDGE_PROVIDER:-demo}
|
||||
RAGCORE_BASE_URL: ${RAGCORE_BASE_URL:-http://ragcore-api:8000}
|
||||
@@ -35,6 +34,9 @@ services:
|
||||
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return}
|
||||
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
|
||||
MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token}
|
||||
DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility}
|
||||
DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels}
|
||||
DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true}
|
||||
ports:
|
||||
- "8128:8000"
|
||||
depends_on:
|
||||
|
||||
@@ -66,6 +66,26 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: Booking detail
|
||||
/api/v1/bookings/{public_ref}/return-preview:
|
||||
post:
|
||||
operationId: previewVehicleReturn
|
||||
description: >-
|
||||
Non-mutating evaluation of what committing this return would do. Shares its
|
||||
domain evaluation with the commit endpoint below so the two can never drift.
|
||||
No writes, no audit event, no outbox event.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/PublicRef'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RegisterReturnRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Authoritative evaluation of the resulting fleet state
|
||||
'409':
|
||||
description: Booking is not active
|
||||
/api/v1/bookings/{public_ref}/return:
|
||||
post:
|
||||
operationId: registerVehicleReturn
|
||||
@@ -94,9 +114,17 @@ paths:
|
||||
/api/v1/data-quality/issues:
|
||||
get:
|
||||
operationId: listDataQualityIssues
|
||||
description: Operations Manager only.
|
||||
responses:
|
||||
'200':
|
||||
description: Quality issues
|
||||
/api/v1/data-quality/scan:
|
||||
post:
|
||||
operationId: runDataQualityScan
|
||||
description: Manual trigger for the deterministic five-rule scan. Operations Manager only.
|
||||
responses:
|
||||
'200':
|
||||
description: Counts of newly created issues per rule type
|
||||
/api/v1/data-quality/issues/{public_ref}/merge-customers:
|
||||
post:
|
||||
operationId: mergeDuplicateCustomers
|
||||
@@ -107,6 +135,106 @@ paths:
|
||||
description: Merge completed
|
||||
'409':
|
||||
description: Issue no longer mergeable
|
||||
/api/v1/data-quality/issues/{public_ref}/provide-fields:
|
||||
post:
|
||||
operationId: provideMissingFields
|
||||
description: >-
|
||||
missing_required_field only. Resolves once nothing required remains missing;
|
||||
otherwise leaves the issue open with updated evidence.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/PublicRef'
|
||||
responses:
|
||||
'200':
|
||||
description: Issue after the update (may still be open)
|
||||
'409':
|
||||
description: Wrong rule type or issue not open
|
||||
'422':
|
||||
description: Disallowed field or empty value
|
||||
/api/v1/data-quality/issues/{public_ref}/resolve-odometer-regression:
|
||||
post:
|
||||
operationId: resolveOdometerRegression
|
||||
description: >-
|
||||
odometer_regression only. Either retains the canonical odometer, or corrects a
|
||||
related booking's reading -- a correction below the current canonical value is
|
||||
rejected, since it would not resolve the regression.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/PublicRef'
|
||||
responses:
|
||||
'200':
|
||||
description: Issue resolved
|
||||
'409':
|
||||
description: Wrong rule type or issue not open
|
||||
'422':
|
||||
description: Invalid booking reference or a correction below canonical
|
||||
/api/v1/data-quality/issues/{public_ref}/resolve-overlap:
|
||||
post:
|
||||
operationId: resolveBookingOverlap
|
||||
description: >-
|
||||
booking_overlap only. Blocks one of the two overlapping bookings and
|
||||
re-verifies no overlap remains before resolving.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/PublicRef'
|
||||
responses:
|
||||
'200':
|
||||
description: Issue resolved
|
||||
'409':
|
||||
description: Wrong rule type, issue not open, or overlap still present
|
||||
'422':
|
||||
description: booking_ref not one of the overlapping bookings
|
||||
/api/v1/data-quality/issues/{public_ref}/apply-recommended-status:
|
||||
post:
|
||||
operationId: applyRecommendedVehicleStatus
|
||||
description: >-
|
||||
vehicle_status_conflict only. Applies the one authoritative recommendation
|
||||
function's output and re-validates before resolving.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/PublicRef'
|
||||
responses:
|
||||
'200':
|
||||
description: Applied status, reason and the resolved issue
|
||||
'409':
|
||||
description: Wrong rule type, issue not open, or no conflict detected
|
||||
/api/v1/search:
|
||||
get:
|
||||
operationId: search
|
||||
description: >-
|
||||
Bounded typed results (vehicle, booking, data_quality_issue, section).
|
||||
Data-quality and manager-only sections are filtered server-side by role.
|
||||
Customers are never returned -- no customer detail route exists.
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 100
|
||||
responses:
|
||||
'200':
|
||||
description: Search results
|
||||
/api/v1/integrations/status:
|
||||
get:
|
||||
operationId: getIntegrationStatus
|
||||
description: >-
|
||||
Truthful aggregate n8n state derived from outbox delivery counts (not just the
|
||||
most recent event), plus the actual MCP Hub registration_enabled setting.
|
||||
Operations Manager only.
|
||||
responses:
|
||||
'200':
|
||||
description: n8n and MCP Hub integration status
|
||||
/api/v1/integrations/n8n/scheduled-scan:
|
||||
post:
|
||||
operationId: n8nScheduledScan
|
||||
description: >-
|
||||
Triggered by the scheduled n8n quality-scan workflow. Runs the same run_scan()
|
||||
the manual UI action uses; idempotent by construction.
|
||||
security:
|
||||
- serviceToken: []
|
||||
responses:
|
||||
'200':
|
||||
description: Counts of newly created issues per rule type
|
||||
'401':
|
||||
description: Invalid service token
|
||||
/api/v1/knowledge/questions:
|
||||
post:
|
||||
operationId: askKnowledgeQuestion
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
container_name="${1:-n8n}"
|
||||
scan_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan}"
|
||||
source_workflow="${3:-n8n/mobilityops-scheduled-quality-scan.json}"
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "Missing deployment .env" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$source_workflow" ]; then
|
||||
echo "Missing workflow export: $source_workflow" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! docker inspect "$container_name" >/dev/null 2>&1; then
|
||||
echo "Existing n8n container not found: $container_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)"
|
||||
if [ -z "$callback_token" ]; then
|
||||
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
|
||||
container_workflow="/tmp/mobilityops-scheduled-quality-scan.json"
|
||||
cleanup() {
|
||||
rm -f "$temporary_workflow"
|
||||
docker exec "$container_name" rm -f "$container_workflow" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
jq --arg scan_url "$scan_url" --arg callback_token "$callback_token" '
|
||||
(.nodes[] | select(.id == "scan-node") | .parameters.url) = $scan_url |
|
||||
(.nodes[] | select(.id == "scan-node") | .parameters.headerParameters.parameters[] |
|
||||
select(.name == "X-Service-Token") | .value) = $callback_token
|
||||
' "$source_workflow" > "$temporary_workflow"
|
||||
|
||||
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
|
||||
docker exec "$container_name" n8n import:workflow --input="$container_workflow"
|
||||
docker exec "$container_name" n8n publish:workflow --id=mobilityops-scheduled-quality-scan
|
||||
docker restart "$container_name" >/dev/null
|
||||
|
||||
echo "Published MobilityOps scheduled quality-scan workflow to existing container ${container_name}"
|
||||
@@ -11,9 +11,17 @@ The demo may use signed server-issued sessions or short-lived JWTs. Demo-role bu
|
||||
### System and demo
|
||||
|
||||
- `GET /health`
|
||||
- `GET /api/v1/system/status`
|
||||
- `GET /api/v1/demo/manifest` — unauthenticated; demo org name/description, synthetic-data
|
||||
flag, reset allowance and timestamp, guide availability, the 5 named scenarios (with
|
||||
live readiness derived from actual records, not hardcoded), and plain-language
|
||||
integration summaries. Single source of truth for the demo-entry screen, the permanent
|
||||
demo badge, the scenario overview and the About page — avoids duplicating this logic
|
||||
per surface.
|
||||
- `POST /api/v1/demo/login`
|
||||
- `POST /api/v1/demo/reset` — Operations Manager only
|
||||
- `GET /api/v1/demo/session` — confirms the current session; `Cache-Control: no-store`
|
||||
- `POST /api/v1/demo/logout` — safe to call without a session
|
||||
- `POST /api/v1/demo/reset` — Operations Manager only; invalidates the caller's own
|
||||
session; returns 403 if `DEMO_ALLOW_RESET=false`
|
||||
|
||||
### Dashboard
|
||||
|
||||
@@ -28,17 +36,37 @@ The demo may use signed server-issued sessions or short-lived JWTs. Demo-role bu
|
||||
|
||||
- `GET /api/v1/bookings`
|
||||
- `GET /api/v1/bookings/{public_ref}`
|
||||
- `POST /api/v1/bookings/{public_ref}/return-preview` — non-mutating; shares its domain
|
||||
evaluation with the commit endpoint below so the two cannot drift apart
|
||||
- `POST /api/v1/bookings/{public_ref}/return`
|
||||
|
||||
Return commands require an `Idempotency-Key` header and optimistic version where relevant.
|
||||
Return commands require an `Idempotency-Key` header. Concurrency safety is row-lock based
|
||||
(`SELECT ... FOR UPDATE` on the booking and vehicle); no optimistic-version field is
|
||||
accepted or needed on top of that.
|
||||
|
||||
### Data quality
|
||||
|
||||
All Operations Manager only.
|
||||
|
||||
- `GET /api/v1/data-quality/issues`
|
||||
- `GET /api/v1/data-quality/issues/{public_ref}`
|
||||
- `POST /api/v1/data-quality/scan` — manual trigger for the deterministic five-rule scan
|
||||
- `POST /api/v1/data-quality/issues/{public_ref}/defer`
|
||||
- `POST /api/v1/data-quality/issues/{public_ref}/reject`
|
||||
- `POST /api/v1/data-quality/issues/{public_ref}/merge-customers`
|
||||
- `POST /api/v1/data-quality/issues/{public_ref}/merge-customers` — possible_duplicate_customer
|
||||
- `POST /api/v1/data-quality/issues/{public_ref}/provide-fields` — missing_required_field
|
||||
- `POST /api/v1/data-quality/issues/{public_ref}/resolve-odometer-regression` — odometer_regression
|
||||
- `POST /api/v1/data-quality/issues/{public_ref}/resolve-overlap` — booking_overlap
|
||||
- `POST /api/v1/data-quality/issues/{public_ref}/apply-recommended-status` — vehicle_status_conflict
|
||||
|
||||
Each of the five rule types has exactly one bounded resolution path above (plus
|
||||
defer/reject, which apply to any open issue).
|
||||
|
||||
### Search
|
||||
|
||||
- `GET /api/v1/search?q=...` — bounded typed results (vehicle, booking,
|
||||
data_quality_issue, section); role-filtered server-side; customers are never returned
|
||||
(no customer detail route exists in this PoC)
|
||||
|
||||
### Knowledge
|
||||
|
||||
@@ -47,9 +75,21 @@ Return commands require an `Idempotency-Key` header and optimistic version where
|
||||
|
||||
### Automation and audit
|
||||
|
||||
- `GET /api/v1/workflows`
|
||||
- `POST /api/v1/workflows/{event_id}/retry`
|
||||
- `GET /api/v1/audit`
|
||||
- `GET /api/v1/workflows` — Operations Manager only
|
||||
- `POST /api/v1/workflows/{event_id}/retry` — Operations Manager only
|
||||
- `GET /api/v1/audit` — Operations Manager only; each event includes `before`/`after`
|
||||
plus a resolved `entity_ref`/`entity_link` where the entity type supports one
|
||||
- `GET /api/v1/integrations/status` — Operations Manager only; truthful aggregate n8n
|
||||
state from outbox delivery counts (not just the most recent event), and the actual
|
||||
MCP Hub `registration_enabled` setting
|
||||
|
||||
### n8n-service endpoints
|
||||
|
||||
Service-token protected (`X-Service-Token`, same shared secret as the return callback):
|
||||
|
||||
- `POST /api/v1/integrations/n8n/return-callback`
|
||||
- `POST /api/v1/integrations/n8n/scheduled-scan` — triggered by the scheduled
|
||||
quality-scan workflow; runs the same domain scan the manual UI action uses
|
||||
|
||||
### MCP-provider endpoints
|
||||
|
||||
|
||||
@@ -25,14 +25,29 @@ Merge rewires booking references, preserves the loser as a tombstone and audits
|
||||
|
||||
Required for active customers: first name, last name and at least one of email or phone. Required for active vehicles: registration number, make, model and location.
|
||||
|
||||
Resolution: `POST /provide-fields` accepts only the fields the entity type actually
|
||||
requires (rejects anything else), applies them, and re-runs the same missing-field check.
|
||||
The issue resolves only once nothing required remains missing; a partial submission
|
||||
updates the record and its evidence but leaves the issue open.
|
||||
|
||||
## DQ-03 Odometer regression
|
||||
|
||||
Flag an inspection or maintenance reading below the canonical odometer. Never lower the canonical value automatically.
|
||||
|
||||
Resolution: `POST /resolve-odometer-regression` offers exactly two bounded decisions —
|
||||
`retain_canonical` (the submitted reading is treated as erroneous; canonical is
|
||||
untouched) or `correct_reading` (updates a named related booking's reading and the
|
||||
vehicle's canonical odometer together). A `correct_reading` value below the current
|
||||
canonical is rejected, since it would not resolve the regression, not silently applied.
|
||||
|
||||
## DQ-04 Booking overlap
|
||||
|
||||
Flag overlapping `reserved` or `active` bookings for one vehicle. Normal write APIs reject new overlaps; the seed/import path may create one controlled legacy conflict.
|
||||
|
||||
Resolution: `POST /resolve-overlap` blocks one of the two named overlapping bookings
|
||||
(minimal safe resolution, not a scheduling calendar) and re-verifies no
|
||||
reserved/active overlap remains among the issue's related bookings before resolving.
|
||||
|
||||
## DQ-05 Vehicle status conflict
|
||||
|
||||
Examples:
|
||||
@@ -42,6 +57,16 @@ Examples:
|
||||
- status `available` while critical open quality issue exists;
|
||||
- status `maintenance` with an active booking.
|
||||
|
||||
Resolution: `POST /apply-recommended-status` computes a recommendation from one
|
||||
authoritative function mirroring the conditions above, applies it, and re-runs the same
|
||||
function to confirm the conflict is actually gone before resolving.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Detection is idempotent by `(rule_type, entity_type, entity_id, evidence fingerprint)` while open. Resolved issues remain historical. Reintroduced evidence creates a new issue linked to the prior issue where useful.
|
||||
Detection is idempotent by `(rule_type, entity_type, entity_id)` while open — the CSV
|
||||
seed rows don't carry a stable evidence fingerprint, so the literal
|
||||
`(..., evidence fingerprint)` scheme from an earlier draft of this rule was dropped as
|
||||
unworkable for seeded data; re-implementing it would need to reconcile with that. Resolved
|
||||
issues remain historical. Reintroduced evidence creates a new issue whose evidence carries
|
||||
`reopened_from` (the prior issue's reference) and `previous_decision` (its resolved
|
||||
status), so a repeat problem is never presented as if no decision was ever made.
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Vehicle-return workflow
|
||||
|
||||
## Preview
|
||||
|
||||
`POST /api/v1/bookings/{public_ref}/return-preview` takes the same request body as the
|
||||
commit endpoint below and runs the identical evaluation (`evaluate_return()`) with no
|
||||
writes, no audit event and no outbox event — it exists so the UI's review step shows the
|
||||
server's actual answer instead of guessing the outcome client-side. It returns the
|
||||
canonical and submitted odometer readings, whether the submission is a regression, the
|
||||
resulting vehicle status with a human-readable reason, whether a quality issue would be
|
||||
created, and next-booking risk. `register_vehicle_return` (below) calls the same
|
||||
`evaluate_return()` function, so preview and commit cannot drift apart.
|
||||
|
||||
## Input
|
||||
|
||||
- booking public reference;
|
||||
|
||||
@@ -18,7 +18,34 @@ Steps:
|
||||
|
||||
The starter export is `n8n/mobilityops-return-processing.json`. Claude may correct its credentials and callback route but must preserve idempotency.
|
||||
|
||||
## Optional second workflow: knowledge sync
|
||||
## Second live workflow: scheduled quality scan
|
||||
|
||||
RAGcore is not connected in this environment, so the originally sketched "knowledge sync"
|
||||
workflow below remains deferred (see "Deferred: knowledge sync"). The second implemented
|
||||
workflow does not depend on RAGcore or MCP Hub, so it is not blocked by them.
|
||||
|
||||
Input: hourly schedule trigger, or a manual trigger for on-demand testing.
|
||||
|
||||
Steps:
|
||||
|
||||
1. call the narrow, service-token-protected `POST
|
||||
/api/v1/integrations/n8n/scheduled-scan` endpoint;
|
||||
2. the endpoint runs the same deterministic `run_scan()` domain function the manual
|
||||
"Run quality scan" UI action uses, and records a `data_quality_scan_run` audit event
|
||||
with `actor_type=service`;
|
||||
3. return counts of newly created issues per rule type.
|
||||
|
||||
`run_scan()` only ever creates an issue for a condition that does not already have one
|
||||
open, so a duplicate or overlapping trigger (a manual test run firing close to the
|
||||
scheduled one, or a retried HTTP call) does no duplicate domain work.
|
||||
|
||||
The starter export is `n8n/mobilityops-scheduled-quality-scan.json`, imported and
|
||||
published the same way as the return-processing workflow (see
|
||||
`deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It ships with
|
||||
`"active": false` so it cannot fire against any environment until deliberately
|
||||
published with a real service token.
|
||||
|
||||
## Deferred: knowledge sync
|
||||
|
||||
Input: manual trigger or manifest-changed event.
|
||||
|
||||
@@ -28,7 +55,8 @@ Steps:
|
||||
2. call RAGcore ingestion/sync API;
|
||||
3. record per-document results through MobilityOps integration status API.
|
||||
|
||||
This workflow is useful but must not delay the core demo if RAGcore's final API is not ready.
|
||||
Deferred until RAGcore's live ingestion API is available in this environment; must not
|
||||
delay or block the core demo.
|
||||
|
||||
## Outbox dispatcher
|
||||
|
||||
|
||||
@@ -4,6 +4,30 @@
|
||||
|
||||
Role buttons may create a session for a seeded demo identity. All API routes still enforce authorization. Demo reset and customer merge require Operations Manager.
|
||||
|
||||
The browser never treats its own cached copy of the logged-in user as authoritative:
|
||||
`AuthContext` re-verifies against `GET /api/v1/demo/session` on every app load (that
|
||||
response is `Cache-Control: no-store`, so a stale cached "authenticated" response can't
|
||||
survive a logout), and a central 401 listener on the API client clears local auth state
|
||||
from any endpoint, not just the session check. `POST /api/v1/demo/logout` and
|
||||
`POST /api/v1/demo/reset` both invalidate the session cookie server-side.
|
||||
|
||||
## Role matrix
|
||||
|
||||
| Capability | Rental Employee | Operations Manager |
|
||||
|---|---|---|
|
||||
| Dashboard, fleet, vehicle detail, bookings, booking detail | yes | yes |
|
||||
| Register a vehicle return | yes | yes |
|
||||
| Knowledge assistant | yes | yes |
|
||||
| Data-quality workbench (view, scan, all resolutions) | no | yes |
|
||||
| Integrations / automation status and retry | no | yes |
|
||||
| Audit trail | no | yes |
|
||||
| Demo reset | no | yes |
|
||||
|
||||
Enforced server-side (every listed manager-only action returns `403` for Rental
|
||||
Employee, verified by direct API tests, not just a hidden button) and mirrored in the
|
||||
frontend nav (manager-only items are not rendered, not merely disabled) and route guards
|
||||
(direct URL access shows a restricted message rather than partial data).
|
||||
|
||||
## Service authentication
|
||||
|
||||
Use separate scoped credentials for:
|
||||
@@ -42,6 +66,13 @@ Required actions:
|
||||
|
||||
Audit is append-only through the application. Provide filters by actor, action, entity and correlation ID.
|
||||
|
||||
`GET /api/v1/audit` (Operations Manager only) returns `before`/`after` for every event
|
||||
(the columns already existed but were not serialized until this pass) plus a resolved
|
||||
`entity_ref`/`entity_link` for vehicle, booking and data-quality-issue entities (no
|
||||
customer link exists — no customer detail route). The UI shows a human-readable
|
||||
before/after summary per row by default, with the raw before/after/metadata JSON behind
|
||||
a `<details>` disclosure rather than shown unconditionally.
|
||||
|
||||
## Confirmation
|
||||
|
||||
No write-capable MCP actions exist in this PoC. Destructive UI actions such as demo reset and customer merge require explicit confirmation.
|
||||
|
||||
@@ -42,6 +42,19 @@ One seeded outbox/workflow record is failed with a safe simulated connection err
|
||||
|
||||
Question: “What must I do when a vehicle returns with damage?” Expected: answer cites damage handling and return inspection procedures.
|
||||
|
||||
## Date anchoring
|
||||
|
||||
The committed CSVs store absolute ISO timestamps authored around a fixed anchor date
|
||||
(`SEED_AUTHORED_ANCHOR = 2026-08-01` in `backend/app/seed_loader.py`, matching the
|
||||
`--anchor` used to generate them). Every seed/reset shifts every seeded booking,
|
||||
inspection, maintenance and outbox timestamp by `today − SEED_AUTHORED_ANCHOR`, so
|
||||
"today"/"near-future"/"currently overlapping" scenarios stay true to the real moment the
|
||||
environment was (re)seeded instead of decaying as real time passes between resets. Public
|
||||
refs and entity relationships are untouched by the shift — only datetime columns move.
|
||||
`load_seed()` returns the resolved `anchor_date`/`seeded_at`, and records a
|
||||
`demo_data_seeded` audit event carrying both the resolved anchor and the original
|
||||
authoring anchor, so the shift applied on any given reset stays traceable.
|
||||
|
||||
## Demo reset
|
||||
|
||||
Reset must:
|
||||
@@ -49,6 +62,7 @@ Reset must:
|
||||
- require Operations Manager;
|
||||
- rebuild the deterministic dataset;
|
||||
- re-establish scenario references;
|
||||
- re-anchor scenario dates to the real reset moment (see above);
|
||||
- clear non-seed audit/workflow state;
|
||||
- complete safely and visibly;
|
||||
- be covered by a test.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# PoC runbook
|
||||
|
||||
For the demo-specific 5-minute/10-minute walkthroughs, reset behaviour, Unraid
|
||||
redeploy/rollback steps and troubleshooting, see `docs/demo-release/demo-runbook.md`.
|
||||
This document covers general environment bootstrap and n8n setup.
|
||||
|
||||
## Bootstrap (clean checkout)
|
||||
|
||||
```bash
|
||||
@@ -62,6 +66,35 @@ curl -b cookies.txt http://localhost:8128/api/v1/workflows | grep succeeded
|
||||
A failed/offline n8n does not roll back the return — the outbox event simply stays
|
||||
`pending`/`failed` and is safely retryable from the Automation page.
|
||||
|
||||
### Second workflow: scheduled quality scan
|
||||
|
||||
Import and publish the same way:
|
||||
|
||||
```bash
|
||||
make n8n-setup-scan
|
||||
```
|
||||
|
||||
which runs:
|
||||
|
||||
```bash
|
||||
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-scheduled-quality-scan.json
|
||||
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
|
||||
docker compose restart n8n
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8128/api/v1/integrations/n8n/scheduled-scan \
|
||||
-H "X-Service-Token: <MOBILITYOPS_CALLBACK_TOKEN from .env>"
|
||||
# {"created": {...}}
|
||||
```
|
||||
|
||||
Trigger a live run from n8n's own UI ("Manual test trigger" node → Execute Workflow) to
|
||||
confirm the round trip without waiting for the hourly schedule. It does not depend on
|
||||
RAGcore or MCP Hub and ships `"active": false`, so it never fires anywhere until
|
||||
deliberately published with a real service token.
|
||||
|
||||
### Existing shared n8n on the Unraid review server
|
||||
|
||||
The Unraid deployment uses the existing n8n at `http://192.168.10.150:5678`; it does not
|
||||
@@ -85,6 +118,14 @@ inside n8n's protected application data. The callback travels through the Mobili
|
||||
proxy, so the shared n8n container does not need direct database access or membership of
|
||||
the MobilityOps Docker network.
|
||||
|
||||
Publish the scheduled quality-scan workflow the same way:
|
||||
|
||||
```bash
|
||||
./deploy/unraid/setup-scheduled-scan.sh \
|
||||
n8n \
|
||||
http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan
|
||||
```
|
||||
|
||||
## Required operational checks
|
||||
|
||||
- API and web health (`GET /health`, web root `200`);
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# Current demo-readiness gap audit
|
||||
|
||||
Performed 2026-08-03 against `feat/mobilityops-functional-completion` @
|
||||
`e0c7ed60112510687627d20a957af91c8b9db7f8` (verified: correct branch, working tree
|
||||
clean, matches the deployed Unraid revision). Method: read `CLAUDE.md`/`AGENTS.md`
|
||||
(identical, no demo-specific guidance yet), `README.md`, `PROJECT_STATE.md`, the docs
|
||||
list in the brief, `artifacts/final-acceptance/summary.md`,
|
||||
`artifacts/functional-completion/final-summary.md`, `seed/generate_seed.py`,
|
||||
`seed/*.csv`, `backend/app/seed_loader.py`, `backend/app/core/config.py`,
|
||||
`backend/app/api/routers/dashboard.py`; live-browsed the deployed app at
|
||||
`http://192.168.10.150:1236` (login, dashboard, a data-quality issue, a booking detail,
|
||||
knowledge, automation).
|
||||
|
||||
## What already works well (do not rebuild)
|
||||
|
||||
- The premium Control Rail UI, role-based nav, server-backed sessions, return preview/
|
||||
commit, all five data-quality resolution flows, audit before/after, role-aware search,
|
||||
truthful integration status, demo reset, stale-lease recovery and the two n8n
|
||||
workflows are all implemented, tested and live-verified per
|
||||
`artifacts/functional-completion/final-summary.md` — this pass does not touch that
|
||||
business logic.
|
||||
- `docs/13-seed-and-demo-scenarios.md` already names exactly the scenarios this brief
|
||||
wants surfaced: **S1** odometer-regression return (`BK-DEMO-RETURN`/`MO-024`), **S2**
|
||||
duplicate customer (`CUS-0012`/`CUS-0178`, issue `DQ-DEMO-DUPLICATE`), **S4** booking
|
||||
overlap (`MO-016`, issue `DQ-DEMO-OVERLAP`, bookings `BK-DEMO-OVERLAP-A/B`), **S5**
|
||||
failed workflow (seeded failed outbox event, retryable from Automation), **S6**
|
||||
grounded knowledge question. These map directly to the brief's 5 requested scenarios
|
||||
— no new scenarios need to be invented, only surfaced with guidance. (S3, "missing
|
||||
inspection before next booking" on `MO-031`, exists too but isn't one of the 5
|
||||
requested; leave it as-is, it still shows up in the attention queue.)
|
||||
- `seed/generate_seed.py` already produces believable Flemish/Dutch data: first/last
|
||||
name pools (Sofie, Lotte, Bram, Peeters, Janssens, Vermeulen, ...), real Kempen-region
|
||||
towns (Geel, Turnhout, Herentals, Mol, Westerlo, Tessenderlo-Ham), `.test` email
|
||||
addresses, and believable RV/camper makes (Adria, Dethleffs, Hymer, Bürstner, ...).
|
||||
Confirmed live: `DQ-DEMO-DUPLICATE` shows customer "Bram Peeters" (Geel) with email
|
||||
`bram.peeters.12@example.test`. **The underlying data quality is already
|
||||
demo-grade — the gap is structural/experiential, not data flavour.**
|
||||
|
||||
## Confirmed gaps
|
||||
|
||||
### 1. No fictional organisation identity anywhere
|
||||
Login/demo-entry screen says "MobilityOps · Control Centre" and generic marketing copy
|
||||
("Every hand-off. One clear view.") but never names a demonstration organisation. A
|
||||
stranger cannot tell *whose* fleet this is. **Confirmed via full-repo search:**
|
||||
"Northstar Mobility" is never rendered in the UI, but it is **already the project's
|
||||
locked fictitious tenant name** — `PROJECT_STATE.md`'s "Locked decisions" section
|
||||
states verbatim *"Fictitious tenant: Northstar Mobility Demo"*, and
|
||||
`ragcore_tenant` defaults to `"northstar-mobility-demo"` in `backend/app/core/config.py`,
|
||||
`compose.yaml`, `.env.example`, `knowledge/manifest.json` and `contracts/mcp-tools.json`.
|
||||
So using "Northstar Mobility" as the visible demo brand (as this brief suggests) is not
|
||||
a new invention — it surfaces a decision the project already made and only ever used
|
||||
internally as a RAGcore-tenant slug. No renaming/config changes needed, just make it
|
||||
visible.
|
||||
|
||||
### 2. No guided path — a visitor has no idea where to start
|
||||
The dashboard shows real attention-queue items (duplicate customer, booking overlap,
|
||||
vehicle status conflict, missing field) and today's movements (`BK-DEMO-RETURN`
|
||||
11:00), but nothing tells a first-time visitor *which* of these is worth clicking, in
|
||||
what order, or why. There is no "start guided demo" affordance, no scenario overview,
|
||||
no step-by-step walkthrough. Everything technically works; nothing narrates.
|
||||
|
||||
### 3. Live date anchoring is broken — confirmed, reproducible
|
||||
`seed/bookings.csv` stores **absolute fixed ISO timestamps** authored around anchor
|
||||
date 2026-08-01 (`generate_seed.py --anchor 2026-08-01`). `backend/app/core/config.py`'s
|
||||
`demo_today` setting (`"2026-08-01"`) is used *only* by
|
||||
`dashboard.py::_today()` to filter "today's movements" against that frozen date — nothing
|
||||
re-anchors the underlying booking/inspection/maintenance/outbox dates themselves.
|
||||
**Live-confirmed right now** (today is 2026-08-03): `BK-DEMO-RETURN` shows status
|
||||
`active` with `ends_at = 01/08/2026 09:00` — already two days in the past, for a
|
||||
booking that's supposed to look "in progress, due for return soon." `BK-DEMO-NEXT`
|
||||
(meant to read as "near-future") starts 2026-08-02, also already past. Every day this
|
||||
environment isn't reset, this gets worse, and after any `seed --reset` it snaps right
|
||||
back to the same frozen 2026-08-01-relative positions regardless of the real reset
|
||||
moment. This is exactly the failure mode brief section 7 describes and must be fixed at
|
||||
the source (`seed_loader.py`), not papered over in the UI.
|
||||
|
||||
### 4. Knowledge assistant names "RAGcore" directly, not honestly "demo mode"
|
||||
`/knowledge` page copy: "Answers are shown only when **RAGcore** returns sufficient
|
||||
cited evidence" and a "RAGcore" label on the sources block — while the actual active
|
||||
provider is `KNOWLEDGE_PROVIDER=demo` (confirmed: badge below correctly shows
|
||||
`demo · Available · 10 procedures indexed`, which contradicts the body copy one line
|
||||
below it). This is the exact misleading-integration-status problem brief section 11
|
||||
warns against — the small badge is honest, the prose isn't. No suggested questions are
|
||||
offered.
|
||||
|
||||
### 5. Integration status labels are technical, not demo-plain-language
|
||||
Automation page shows raw states like `not_configured`/`degraded`/`operational` (English,
|
||||
developer register) rather than the plain-language labels section 12 asks for
|
||||
(Operationeel / Demomodus / Niet gekoppeld / Voorbereid / Verwerking mislukt / Opnieuw
|
||||
proberen mogelijk).
|
||||
|
||||
### 6. Data-quality resolution panels are functionally complete but not narrated
|
||||
Confirmed live on `DQ-DEMO-DUPLICATE`: the compare/merge UI works well, but nothing
|
||||
explains *why this matters operationally* (duplicate billing/contact risk) or what
|
||||
happens next in plain terms before the user acts. Same pattern across the other 4
|
||||
rule-type panels (already reviewed in this session's earlier batch). No "demo scenario"
|
||||
filter exists to surface the curated issues first.
|
||||
|
||||
### 7. No "About this demo" page
|
||||
No page anywhere explains what's real, what's synthetic, what's live vs. demo-mode vs.
|
||||
not-connected, or the architecture/testing approach in plain terms. A visitor exploring
|
||||
alone has no way to self-answer "is this real?"
|
||||
|
||||
### 8. No permanent, explained "synthetic demo" indicator
|
||||
The existing `demo-banner` ("Synthetic demo data · no real customer or vehicle
|
||||
information") is a full-width static bar repeated on every page — present, but static
|
||||
text only, no link to more explanation, no reset-timestamp/anchor info, and (per section
|
||||
4) shouldn't be a dominant warning bar; it should be a subtle, explained badge.
|
||||
|
||||
### 9. Demo reset exists but is not discoverable as a demo feature
|
||||
`POST /api/v1/demo/reset` and its sidebar UI trigger (this session's earlier batch)
|
||||
work correctly and are Operations-Manager-gated, but reset isn't connected to any demo
|
||||
narrative (no "prepare demo again" framing, not reachable from an About page or guide,
|
||||
no post-reset scenario-integrity confirmation beyond the raw counts).
|
||||
|
||||
### 10. No demo manifest / no single source of "what's on right now"
|
||||
Nothing exposes demo mode, scenario list, integration status and guidance availability
|
||||
as one small structured payload the frontend can render consistently (badge tooltip,
|
||||
guide, scenario overview, About page all currently would have to duplicate this
|
||||
knowledge if built independently).
|
||||
|
||||
### 11. Everything is in English; the brief asks for consistent Dutch demo text
|
||||
All existing UI copy is English. The brief explicitly asks for consistent Dutch product
|
||||
text for the demo-facing additions. Recorded decision (no existing doc answers this,
|
||||
and translating the entire already-shipped, already-tested Control Rail UI is out of
|
||||
proportion to "add guidance," would touch dozens of files and contradicts "this is not
|
||||
a redesign"): **new demo-productization surfaces are written in Dutch** (demo entry
|
||||
copy, Demo Guide, scenario overview, About-this-demo page, the demo badge/tooltip,
|
||||
plain-language integration-status labels). **Existing operational screens (Dashboard,
|
||||
Vehicles, Bookings, Data Quality workbench, Audit, Automation internals) stay in
|
||||
English** — they are already shipped, tested, and section 16 explicitly forbids
|
||||
rebuilding working screens without functional reason. This mirrors how a real bilingual
|
||||
guided demo works: narration in one language, software in whatever it already ships in.
|
||||
|
||||
## Scope reminder (not gaps — explicitly out of scope per the brief)
|
||||
|
||||
- No RAGcore or MCP Hub live implementation.
|
||||
- No new business logic in the 5 resolution flows — only explanatory framing around
|
||||
the existing ones.
|
||||
- No full redesign, new component library, new color palette, or full retranslation.
|
||||
|
||||
## Plan (implementation batches, matches task list)
|
||||
|
||||
1. Fix date anchoring in `seed_loader.py` (shift all seeded dates by `today − anchor`
|
||||
at every reset); switch `dashboard.py::_today()` to real wall-clock date; add a
|
||||
`demo_data_seeded` audit marker recording the anchor/reset moment.
|
||||
2. `GET /api/v1/demo/manifest` (org name, demo mode, scenarios, integration status,
|
||||
reset timestamp) + Dutch demo entry screen + permanent demo badge with popover.
|
||||
3. Demo Guide (collapsible panel/bottom sheet, 8 steps) + scenario overview (5 cards)
|
||||
on the dashboard.
|
||||
4. Layer plain-language Dutch explanation onto the return flow, the 5 DQ panels and the
|
||||
knowledge assistant (fix the RAGcore-naming bug), without touching their logic.
|
||||
5. Plain-language integration status labels, richer audit narration for guide-linked
|
||||
events, "Over deze demo" page, reset wired into guide/About/OM menu with a
|
||||
post-reset scenario-integrity check.
|
||||
6. Full guided-demo Playwright test + targeted tests per section 19.
|
||||
7. Clean-checkout demo drill, docs, Unraid deploy, final evidence with screenshots.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Demo concept
|
||||
|
||||
## The fictional problem
|
||||
|
||||
**Northstar Mobility** is a fictional Belgian camper/van rental company with roughly 50
|
||||
vehicles, one main location, a rental team, an operations manager, and a small workshop.
|
||||
Northstar Mobility does not exist — this name and every customer, vehicle, booking and
|
||||
procedure in the demo are synthetic. Before MobilityOps, Northstar Mobility's bookings,
|
||||
returns, customer records and maintenance history lived in spreadsheets and verbal
|
||||
hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked
|
||||
vehicles) only surfaced late, after they had already caused friction. MobilityOps shows
|
||||
how one connected system detects these problems early and lets an Operations Manager
|
||||
resolve them under audit, with automation handling the routine follow-up.
|
||||
|
||||
## Target audience
|
||||
|
||||
Anyone evaluating how MobilityOps approaches operational data-quality and hand-off
|
||||
problems for a small rental fleet: Operations Managers, Rental Employees, and reviewers
|
||||
assessing the approach. No account setup or backend knowledge is required to start —
|
||||
the login screen explains the fictional context and offers a guided path.
|
||||
|
||||
## Demo scope
|
||||
|
||||
This is a focused proof of concept, not a full ERP. In scope: vehicle and booking
|
||||
management, return processing with server-side evaluation, five data-quality detection
|
||||
rules each with one bounded resolution flow, a full audit trail, and orchestration via a
|
||||
real n8n instance. Explicitly out of scope: accounting, payments, public reservations, a
|
||||
generic CRM, inventory, HR, a second RAG stack, or autonomous write actions from any
|
||||
external tool.
|
||||
|
||||
## What's really implemented
|
||||
|
||||
All of the following is functional code, not a mockup or hardcoded screen:
|
||||
|
||||
- Role-based authentication and server-verified sessions (Operations Manager, Rental
|
||||
Employee), enforced both server-side and in the frontend's navigation/route guards.
|
||||
- Vehicle and booking management, with return preview/commit sharing one authoritative
|
||||
evaluation function so they can never drift.
|
||||
- Five data-quality rules (possible duplicate customer, missing required field, odometer
|
||||
regression, booking overlap, vehicle status conflict), each with exactly one bounded,
|
||||
audited resolution endpoint.
|
||||
- A full audit trail with before/after state, actor, correlation IDs, and a
|
||||
"view related events" link reusing that correlation.
|
||||
- Reliable outbox-based delivery to a real n8n instance, with bounded retries and
|
||||
stale-delivery recovery.
|
||||
- Docker Compose deployment and an automated test suite (backend pytest, Ruff, mypy,
|
||||
and a full Playwright end-to-end suite covering the demo experience itself).
|
||||
- A demo manifest (`GET /api/v1/demo/manifest`) as the single source of truth for the
|
||||
demo's identity, synthetic-data status, reset state, scenario readiness, and honest
|
||||
per-integration labels — the frontend never hardcodes what mode it's in.
|
||||
|
||||
## What's synthetic
|
||||
|
||||
The organisation, all customers, vehicles, bookings, maintenance history, the knowledge
|
||||
base's procedures, and the five prepared demo scenarios are entirely fictional. No data
|
||||
refers to a real person, vehicle, or company. Every seeded email uses the `.test`
|
||||
reserved domain. See [`demo-data.md`](demo-data.md) for exactly how this data is
|
||||
generated and kept fresh across resets.
|
||||
|
||||
## What's not yet live-connected
|
||||
|
||||
- **RAGcore**: not connected. The knowledge assistant uses a local, deterministic
|
||||
keyword-matching "demo knowledge base" built from five procedure documents, honestly
|
||||
labelled as such everywhere in the UI (never presented as RAGcore). A
|
||||
`RAGcoreKnowledgeProvider` HTTP adapter exists and is unit-tested, ready to take over
|
||||
the same interface once a real RAGcore backend is available — swapping providers is a
|
||||
configuration change (`KNOWLEDGE_PROVIDER`), not a UI change.
|
||||
- **ITWorx MCP Hub**: not connected. Registration is disabled by default
|
||||
(`MCP_HUB_REGISTRATION_ENABLED=false`) and the UI always shows "Not connected" —
|
||||
never a fabricated successful registration.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [`demo-scenarios.md`](demo-scenarios.md) — the five guided scenarios and their fixed records.
|
||||
- [`demo-data.md`](demo-data.md) — seed strategy and date-anchoring.
|
||||
- [`demo-guide.md`](demo-guide.md) — the in-app 8-step guided tour.
|
||||
- [`demo-runbook.md`](demo-runbook.md) — how to run, reset, and redeploy the demo.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Demo data strategy
|
||||
|
||||
## Three separate concepts
|
||||
|
||||
It's important to keep these distinct — they solve different problems:
|
||||
|
||||
1. **Deterministic records** — every seeded entity has a stable public reference
|
||||
(`BK-DEMO-RETURN`, `DQ-DEMO-DUPLICATE`, `MO-024`, `CUS-0012`, ...). These references
|
||||
never change between seed generations or resets; they are what the five demo
|
||||
scenarios, the Demo Guide, and `docs/13-seed-and-demo-scenarios.md` all link against.
|
||||
2. **Date anchoring** — the mechanism that keeps "today"/"near-future"/"currently
|
||||
overlapping" scenarios true to whenever the environment was actually last reset,
|
||||
described below.
|
||||
3. **The reset date** — the real wall-clock moment a reset actually happened. This
|
||||
changes every time someone resets; it's the input to date anchoring, not a fixed
|
||||
record.
|
||||
|
||||
## Generating the seed dataset
|
||||
|
||||
```bash
|
||||
python seed/generate_seed.py --anchor 2026-08-01 --seed 20260801
|
||||
```
|
||||
|
||||
This produces the committed CSVs in `seed/*.csv` with **absolute ISO timestamps**
|
||||
authored relative to a fixed anchor date (`2026-08-01`). Target scale: 50 vehicles, 180
|
||||
customers (including three duplicate pairs), ~245 historical/current/future bookings,
|
||||
realistic inspections and maintenance history, and the fixed quality/workflow scenarios
|
||||
described in [`demo-scenarios.md`](demo-scenarios.md). Names, towns and vehicle makes are
|
||||
drawn from believable Flemish/Kempen-region pools; all emails use the `.test` domain.
|
||||
|
||||
## Date anchoring
|
||||
|
||||
`backend/app/seed_loader.py` defines:
|
||||
|
||||
```python
|
||||
SEED_AUTHORED_ANCHOR = date(2026, 8, 1) # matches generate_seed.py's --anchor
|
||||
```
|
||||
|
||||
Every seed/reset computes `shift = today - SEED_AUTHORED_ANCHOR` and applies it to every
|
||||
seeded booking, inspection, maintenance and outbox timestamp before insertion. Public
|
||||
references and entity relationships are never touched by the shift — only datetime
|
||||
columns move. This means:
|
||||
|
||||
- `BK-DEMO-RETURN` always ends "today" (or very close to it) relative to whenever you
|
||||
actually reset, not relative to the frozen 2026-08-01 authoring date.
|
||||
- `BK-DEMO-NEXT` and the overlap-scenario bookings always read as "near future".
|
||||
- The shift is recomputed fresh on every reset, so scenarios never decay as real time
|
||||
passes between resets — this was a real, confirmed bug before this fix (see
|
||||
`docs/demo-release/current-demo-gap-audit.md`, gap #3): the environment would drift
|
||||
further out of sync with every day it wasn't reset, and a reset didn't fix it because
|
||||
nothing re-anchored the underlying stored dates.
|
||||
|
||||
`load_seed()` returns the resolved `anchor_date` (real today) and `seeded_at` timestamp,
|
||||
and records a `demo_data_seeded` audit event carrying both the resolved anchor and the
|
||||
original authoring anchor, so the shift applied on any given reset stays traceable via
|
||||
the audit trail.
|
||||
|
||||
`dashboard.py::_today()` uses real wall-clock UTC date (not a frozen setting) to filter
|
||||
"today's movements", consistent with the shifted data.
|
||||
|
||||
## Reset
|
||||
|
||||
`POST /api/v1/demo/reset` (Operations Manager only, and only if `DEMO_ALLOW_RESET=true`)
|
||||
clears all MobilityOps tables, reloads the seed with a fresh date shift, re-runs the
|
||||
data-quality scan, and runs a server-side **scenario-integrity check**
|
||||
(`scenario_integrity_report()` in `backend/app/services/demo_manifest.py`) confirming all
|
||||
five named scenarios are actually present and ready — recorded in both the response body
|
||||
and the `demo_reset` audit event's metadata. Reset only ever affects MobilityOps's own
|
||||
tables; it never touches shared n8n, RAGcore, or MCP data, other containers, or volumes.
|
||||
|
||||
## Seed-validation tests
|
||||
|
||||
`backend/tests/test_seed.py` proves, after every reset:
|
||||
|
||||
- S1 (`BK-DEMO-RETURN`/`MO-024`) is active with no end odometer recorded yet.
|
||||
- S2 (`CUS-0012`/`CUS-0178`/`DQ-DEMO-DUPLICATE`) is open with matching evidence.
|
||||
- S4 (`MO-016`/`BK-DEMO-OVERLAP-A`/`-B`/`DQ-DEMO-OVERLAP`) genuinely overlaps in time.
|
||||
- S5 (the seeded failed outbox event) is durably `failed` immediately after reset, not
|
||||
silently auto-healed by the background dispatcher (which only claims `pending` rows).
|
||||
- The date-anchoring shift and the `demo_data_seeded` audit marker are both correct.
|
||||
|
||||
`backend/tests/test_demo_manifest.py` additionally proves that all five manifest
|
||||
scenarios report `ready: true` with no `blocked_reason` right after a fresh reset.
|
||||
@@ -0,0 +1,71 @@
|
||||
# The in-app Demo Guide
|
||||
|
||||
## What it is (and isn't)
|
||||
|
||||
The Demo Guide is a compact, reusable side panel (a bottom sheet on mobile) that walks an
|
||||
Operations Manager through eight fixed steps covering the demo's core functionality. It is
|
||||
**not** a generic tutorial engine and **not** a source of business logic — every action it
|
||||
prompts is a real action against the real API; the guide only narrates, links, and tracks
|
||||
progress. It duplicates no business logic: routes for the two scenario-backed steps
|
||||
(return, duplicate-merge) are resolved from the live `GET /api/v1/demo/manifest` response
|
||||
rather than hardcoded, so they can never point at a stale or missing record.
|
||||
|
||||
## Where it lives
|
||||
|
||||
- `frontend/src/data/demoGuideSteps.ts` — the eight step definitions (title, what you'll
|
||||
see, why it matters, the exact start action, the expected outcome, and a `route()`
|
||||
function).
|
||||
- `frontend/src/context/DemoGuideContext.tsx` — open/close state and step progress,
|
||||
persisted to `sessionStorage` only (browser-local, never touches auth or business
|
||||
state; a "Demo opnieuw voorbereiden" restart or a new browser session starts fresh).
|
||||
- `frontend/src/components/DemoGuide.tsx` — the panel itself and its topbar trigger
|
||||
(`DemoGuideTrigger`, Operations-Manager-only, since all eight steps require that role).
|
||||
|
||||
## The eight steps
|
||||
|
||||
1. **Understand the operational state** — the dashboard's readiness and attention queue.
|
||||
2. **Open the booking needing attention** — `BK-DEMO-RETURN`, resolved from the manifest.
|
||||
3. **Process the return with an odometer anomaly** — same booking; the return form is
|
||||
pre-filled with the suspicious reading (see [`demo-scenarios.md`](demo-scenarios.md)
|
||||
scenario 1).
|
||||
4. **Handle the created data-quality issue** — the fresh odometer-regression issue that
|
||||
step 3 just created.
|
||||
5. **Review and merge the possible duplicate customer** — `DQ-DEMO-DUPLICATE`.
|
||||
6. **Ask the procedure assistant a question** — one of the suggested questions.
|
||||
7. **Check automation and the audit trail**.
|
||||
8. **Review what's real, simulated, or not yet connected** — the About page.
|
||||
|
||||
## How progression works
|
||||
|
||||
- **"Ga naar deze stap"** navigates to the step's resolved route without marking it done.
|
||||
- **"Volgende"** marks the current step complete and advances the index (used for steps
|
||||
with no dedicated in-page continuation, like the knowledge-assistant step).
|
||||
- Several real actions (a successful return, a resolved data-quality issue) show their
|
||||
own **"Ga verder met de demo"** button that both completes the current step and
|
||||
navigates straight to the next one — this is how the guide chains through steps 3→4→5
|
||||
without a detour back through the panel's own controls.
|
||||
- The step list on the panel lets you jump directly to any step.
|
||||
- **"Demo opnieuw voorbereiden"** calls the real reset endpoint, resets the guide's own
|
||||
progress, and returns to the login screen — mirroring the existing sidebar reset
|
||||
control, not a separate implementation.
|
||||
|
||||
## A known, deliberate limitation: suggested questions stay in English
|
||||
|
||||
The demo knowledge base's five procedure documents (`knowledge/procedures/*.md`) are
|
||||
written in English. Verified empirically while building step 6: an equivalent Dutch
|
||||
question returned `insufficient` evidence against the demo provider, while the English
|
||||
original returned `grounded`. Rather than mistranslate the demo's centerpiece "grounded
|
||||
answer" feature into something that silently answers incorrectly, the suggested questions
|
||||
on `/knowledge` and this guide's step 6 instructions stay in English, with the guide
|
||||
explicitly explaining why in Dutch. Retranslating the procedure documents themselves was
|
||||
judged out of scope for a demo-productization pass (see the language-split note in
|
||||
`docs/demo-release/current-demo-gap-audit.md`, gap #11).
|
||||
|
||||
## Layout note: the panel reserves space, it doesn't overlap content
|
||||
|
||||
On desktop the panel is a fixed 400px-wide right-side overlay. `Layout.tsx` adds a
|
||||
`guide-open` class to the main workspace while the guide is open, which reserves
|
||||
`padding-right` so page content reflows aside instead of sitting underneath the panel —
|
||||
this was a real bug found and fixed while building the full guided-demo Playwright test
|
||||
(see `PROJECT_STATE.md`, Batch 6): without it, the return form's "Review return" button
|
||||
was unclickable while the guide was open at ordinary desktop widths.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Demo runbook
|
||||
|
||||
## Starting the demo (any environment)
|
||||
|
||||
Open the deployed URL (Unraid review: `http://192.168.10.150:1236`; local:
|
||||
`http://localhost:1228`). The login screen names the fictional organisation, states that
|
||||
all data is synthetic and all workflows are really implemented, and offers:
|
||||
|
||||
- **Start begeleide demo** — logs in as Operations Manager and opens the Demo Guide at
|
||||
step 1.
|
||||
- **Verken als Operations Manager** / **Verken als Rental Employee** — free exploration,
|
||||
no guide.
|
||||
|
||||
No password is shown or required for either path.
|
||||
|
||||
## Five-minute demo (guided)
|
||||
|
||||
1. Click **Start begeleide demo**.
|
||||
2. Follow steps 1–3: dashboard → the booking needing attention → confirm the pre-filled
|
||||
odometer-anomaly return.
|
||||
3. Step 4: resolve the data-quality issue the return just created (any decision is fine
|
||||
for a quick pass — "Retain canonical" is the fastest).
|
||||
4. Step 6: ask a suggested knowledge question and show the cited source.
|
||||
5. Step 7: show the automation/audit trail link-through.
|
||||
6. Close with step 8, the About page's honest real/synthetic/not-connected breakdown.
|
||||
|
||||
## Ten-minute demo (guided + one extra scenario)
|
||||
|
||||
Do the five-minute path above, then from `/scenarios`:
|
||||
|
||||
- Run **scenario 2** (duplicate customer merge) if not already done via the guide's own
|
||||
step 5.
|
||||
- Run **scenario 3** (booking overlap) — `/data-quality`, resolve `DQ-DEMO-OVERLAP`.
|
||||
- Run **scenario 4** (failed automation retry) — `/automation`, filter to failed, retry.
|
||||
|
||||
All five scenarios can be run in any order and are independent of each other.
|
||||
|
||||
## Resetting the environment
|
||||
|
||||
Any Operations Manager can reset from: the sidebar ("Reset demo data"), the Demo Guide
|
||||
panel ("Demo opnieuw voorbereiden"), or the About page (points to the sidebar control).
|
||||
Reset requires confirmation, rebuilds the deterministic dataset with a fresh date anchor,
|
||||
runs a server-side scenario-integrity check, and signs the acting session out (the server
|
||||
invalidates the session as part of reset). It only ever touches MobilityOps's own tables
|
||||
— never shared n8n, RAGcore, or MCP data, other containers, or volumes. It can be
|
||||
disabled entirely via `DEMO_ALLOW_RESET=false` if an environment must not be rebuildable.
|
||||
|
||||
## Redeploying to Unraid
|
||||
|
||||
```bash
|
||||
# From a clean local checkout on the target branch/commit:
|
||||
git archive --format=tar.gz -o /tmp/mobilityops-source.tar.gz HEAD
|
||||
scp /tmp/mobilityops-source.tar.gz unraid:/mnt/user/appdata/mobilityops/.deploy/source-<short-sha>.tar.gz
|
||||
ssh unraid "cd /mnt/user/appdata/mobilityops \
|
||||
&& tar -xzf .deploy/source-<short-sha>.tar.gz \
|
||||
&& echo <full-sha> > .deploy/source-revision"
|
||||
|
||||
# Rebuild only what changed (api and/or web); db is never rebuilt:
|
||||
ssh unraid "cd /mnt/user/appdata/mobilityops \
|
||||
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web"
|
||||
|
||||
# Confirm migrations and reseed:
|
||||
ssh unraid "cd /mnt/user/appdata/mobilityops \
|
||||
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m alembic current \
|
||||
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m app.cli seed --reset"
|
||||
```
|
||||
|
||||
Extraction preserves the server's existing `.env` and named volumes (Postgres data, n8n
|
||||
data) — the tarball never contains `.env` since it's gitignored. Never edit source
|
||||
directly on the server; never deploy uncommitted changes.
|
||||
|
||||
## Rollback
|
||||
|
||||
`.deploy/source-revision` on the server records exactly which commit is live. Prior
|
||||
source tarballs remain in `.deploy/` for rollback: extract an earlier
|
||||
`source-<short-sha>.tar.gz`, update `source-revision` to match, and re-run the rebuild
|
||||
step above. Database rollback is out of scope for this demo (migrations are additive;
|
||||
there has been no destructive migration on this branch).
|
||||
|
||||
## Server safety (Unraid)
|
||||
|
||||
Only touch the `mobilityops` Compose project's own `api`/`web` services (and `db` only
|
||||
via migrations, never manually). Never stop other containers, run `docker system prune`,
|
||||
delete unrecognised images/networks/volumes, delete the MobilityOps database, overwrite
|
||||
the server `.env`, print secrets, start a second permanent n8n instance, or activate
|
||||
guessed RAGcore/MCP URLs. PostgreSQL is never exposed externally.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **A scenario shows "Niet beschikbaar" on `/scenarios`**: it has already been resolved
|
||||
since the last reset (expected once you've worked through it) — reset to restore it.
|
||||
- **Knowledge question returns "insufficient evidence"**: check the question is in
|
||||
English and close to one of the suggested phrasings — the demo knowledge base is
|
||||
English-only (see `demo-guide.md`).
|
||||
- **n8n shows a failed delivery that isn't the seeded demo one**: check `/automation`'s
|
||||
filter and retry — bounded retries mean it should self-heal within
|
||||
`n8n_max_attempts` attempts, or can be retried manually by an Operations Manager.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Demo scenarios
|
||||
|
||||
Five fixed scenarios are surfaced at `/scenarios` and via `GET /api/v1/demo/manifest`'s
|
||||
`scenarios` array, which derives each scenario's `ready`/`blocked_reason` from the actual
|
||||
underlying records — never hardcoded. All five are re-findable, using the same public
|
||||
references, after every demo reset (see [`demo-data.md`](demo-data.md)).
|
||||
|
||||
## 1 — Odometer regression on return
|
||||
|
||||
- **Fixed records**: booking `BK-DEMO-RETURN` on vehicle `MO-024`.
|
||||
- **Role**: Rental Employee or Operations Manager.
|
||||
- **Problem demonstrated**: a return is submitted with an odometer reading lower than the
|
||||
vehicle's canonical reading — usually a data-entry mistake or a return logged against
|
||||
the wrong vehicle.
|
||||
- **Flow**: open the booking. The return form is pre-filled with a suspicious reading
|
||||
below `MO-024`'s canonical odometer (the demo never asks a visitor to invent one), with
|
||||
a callout explaining why. Review the server-evaluated preview, then confirm. The
|
||||
canonical odometer is never silently lowered; a data-quality issue is opened
|
||||
automatically and the outcome is queued for delivery to n8n.
|
||||
- **Continue**: the result panel links to the vehicle, automation status, and audit
|
||||
trail, plus (inside the guided tour) a "Ga verder met de demo" button.
|
||||
|
||||
## 2 — Possible duplicate customer
|
||||
|
||||
- **Fixed records**: customers `CUS-0012` and `CUS-0178` (same email/phone), issue
|
||||
`DQ-DEMO-DUPLICATE`.
|
||||
- **Role**: Operations Manager (merge is a bounded, audited action).
|
||||
- **Problem demonstrated**: two customer profiles that are very likely the same person,
|
||||
registered twice — a risk for split booking history and duplicate contact.
|
||||
- **Flow**: open the issue, compare both profiles field by field, choose which survives,
|
||||
and merge. The losing profile becomes a tombstone linked to the survivor; its bookings
|
||||
are rewired. The result is recorded in the audit trail.
|
||||
|
||||
## 3 — Overlapping bookings
|
||||
|
||||
- **Fixed records**: vehicle `MO-016`, bookings `BK-DEMO-OVERLAP-A`/`BK-DEMO-OVERLAP-B`,
|
||||
issue `DQ-DEMO-OVERLAP`.
|
||||
- **Role**: Operations Manager.
|
||||
- **Problem demonstrated**: the same vehicle committed to two overlapping reservations —
|
||||
a legacy-import-style conflict that a normal booking command would reject outright.
|
||||
- **Flow**: open the issue, choose which of the two bookings to block; the other keeps
|
||||
its current status.
|
||||
|
||||
## 4 — Failed automation, retried
|
||||
|
||||
- **Fixed record**: a seeded outbox event (`00000000-0000-4000-8000-000000000020`,
|
||||
`BK-H-0020`) that is durably `failed` immediately after every reset — a safe, simulated
|
||||
connection error, not a deliberately destabilised n8n configuration.
|
||||
- **Role**: Operations Manager.
|
||||
- **Problem demonstrated**: bounded retries and visible failure/recovery state for
|
||||
workflow delivery, rather than a silent drop.
|
||||
- **Flow**: open Automation, filter to failed deliveries, retry the event; it moves out
|
||||
of the failed filter once delivered.
|
||||
|
||||
## 5 — Grounded procedure question
|
||||
|
||||
- **Role**: Rental Employee or Operations Manager.
|
||||
- **Problem demonstrated**: an operational question gets an answer with a citation from
|
||||
the demo knowledge base — or an honest "insufficient evidence" if nothing indexed
|
||||
answers it — never an invented answer.
|
||||
- **Flow**: open Knowledge, click one of the suggested questions (English, matching the
|
||||
indexed procedure content — see the note in [`demo-guide.md`](demo-guide.md) about why
|
||||
suggested questions stay in English even though the rest of the demo narrates in
|
||||
Dutch), and inspect the cited source and evidence state.
|
||||
|
||||
## A sixth scenario exists but isn't one of the five requested
|
||||
|
||||
`docs/13-seed-and-demo-scenarios.md`'s **S3** ("missing inspection before next booking",
|
||||
vehicle `MO-031`) is also seeded and shows up in the attention queue; it isn't surfaced
|
||||
on `/scenarios` because the brief names five specific scenarios, but it remains available
|
||||
for anyone exploring the attention queue directly.
|
||||
@@ -0,0 +1,173 @@
|
||||
# Final product polish — audit
|
||||
|
||||
Performed 2026-08-03 against `feat/mobilityops-functional-completion`.
|
||||
|
||||
## Commit state
|
||||
|
||||
- **Local/branch HEAD**: `4a268c73515dc4f1d56c1aa2f231714654bffbb8`
|
||||
- **Deployed on Unraid** (`/mnt/user/appdata/mobilityops/.deploy/source-revision`):
|
||||
`4a268c73515dc4f1d56c1aa2f231714654bffbb8` — **matches exactly**, no drift.
|
||||
- **Repository's primary branch is `master`, not `main`** — no branch named `main` exists
|
||||
(`git branch -a` / `git remote show` confirm only `master`, `design/mobilityops-premium-ui`,
|
||||
and `feat/mobilityops-functional-completion`). `origin/master` = local `master` =
|
||||
`e0c7ed60112510687627d20a957af91c8b9db7f8`, unchanged since the functional-completion
|
||||
baseline — this is the baseline to compare against before any future merge.
|
||||
- **Evidence-file mismatch found and corrected**: `artifacts/demo-release/final-summary.md`
|
||||
recorded final commit `294a8176d19...` (one commit behind actual HEAD, because its own
|
||||
"record the hash" follow-up commit necessarily couldn't self-reference). Corrected to
|
||||
the verified, unambiguous `4a268c73515dc4f1d56c1aa2f231714654bffbb8`, cross-checked
|
||||
against both `git rev-parse HEAD` and the server's `source-revision`.
|
||||
- Containers on Unraid: `api`/`db`/`web` all healthy; migrations at `e7b08389f47f (head)`.
|
||||
|
||||
## Remaining "MobilityOps" mentions (user-facing)
|
||||
|
||||
- `frontend/index.html:7` — `<title>MobilityOps</title>`; `:6` meta description.
|
||||
- `frontend/src/components/Layout.tsx:178` — sidebar brand lockup `<strong>MobilityOps</strong>`.
|
||||
- `frontend/src/components/Layout.tsx:232` — global search `aria-label`/visually-hidden
|
||||
label "Search MobilityOps".
|
||||
- `frontend/src/components/Layout.tsx:299` — footer `<span>MobilityOps PoC</span>` (also
|
||||
the "PoC" mention to remove).
|
||||
- `frontend/src/pages/Login.tsx:27` — the manifest-fallback org description string.
|
||||
- `frontend/src/pages/AboutDemo.tsx:32,63,71` — page heading and two body paragraphs.
|
||||
- `frontend/src/pages/Knowledge.tsx:108` — empty-state copy.
|
||||
- `frontend/src/pages/BookingDetail.tsx:80` — scenario callout copy.
|
||||
- `frontend/src/data/demoGuideSteps.ts:91` — step 8's expected-outcome text.
|
||||
- `backend/app/services/demo_manifest.py:24` — `ORGANIZATION_DESCRIPTION` (the string
|
||||
`Login.tsx:27` falls back to when the manifest hasn't loaded yet — both need updating
|
||||
together to avoid a flash of stale branding).
|
||||
- `backend/app/main.py:36` — FastAPI `title="MobilityOps API"` (visible in the OpenAPI/
|
||||
Swagger UI a reviewer might open; low-risk to rename since it's a display string, not a
|
||||
route or contract field).
|
||||
- **`knowledge/procedures/*.md` — user-facing, appears directly in cited answer text**:
|
||||
`02-vehicle-return.md`, `03-damage-handling.md` (also says "PoC"), `05-cleaning-
|
||||
checklist.md`, `07-customer-documents.md` (also says "PoC"), `09-booking-conflicts.md`
|
||||
all say "MobilityOps" in prose that gets quoted verbatim as a knowledge-assistant
|
||||
answer excerpt — these must be rebranded too, not just the app chrome.
|
||||
- e2e tests asserting on the string "MobilityOps" that must be updated alongside the
|
||||
rename (not gaps, just dependencies): `demo-accessibility.spec.ts`, `demo-entry.spec.ts`,
|
||||
`guided-demo-full.spec.ts` (About-page heading), `ui-redesign.spec.ts` (4× "Search
|
||||
MobilityOps" combobox name).
|
||||
|
||||
**Kept as technical identifiers** (per the brief, not renamed): Git repo `Jens/MobilityOps`,
|
||||
local repo folder name, Compose project `mobilityops`, deployment dir
|
||||
`/mnt/user/appdata/mobilityops`, Postgres db/user `mobilityops`, `knowledge/manifest.json`'s
|
||||
`"workspace": "mobilityops"` (a RAGcore-workspace config value), frontend package name
|
||||
`mobilityops-web`, and the internal API route prefix conventions.
|
||||
|
||||
## Language problems (no i18n exists at all)
|
||||
|
||||
- `frontend/package.json` has no i18n library (`i18next`/`react-i18next`/etc. absent).
|
||||
- There is no language switcher anywhere and no persisted language preference.
|
||||
- The existing UI mixes English (Dashboard, Vehicles, Bookings, Data Quality workbench,
|
||||
Audit, Automation internals — all shipped in Task-B's demo-productization pass) and
|
||||
Dutch (Login, Demo Guide, Scenarios, About, demo badge — added in the same pass) by a
|
||||
documented, deliberate scope decision at the time. That decision must now be superseded:
|
||||
the brief requires one true default (nl-BE) with working en-GB/fr-BE switching across
|
||||
the **entire** application, so the English-language existing screens are now in scope
|
||||
for translation, not just new surfaces.
|
||||
- The demo knowledge base (`knowledge/procedures/*.md`) is English-only; a Dutch or French
|
||||
question against it returns `insufficient` evidence (verified empirically in the prior
|
||||
work) — this is a real gap for the trilingual guided demo requirement.
|
||||
|
||||
## Remaining technical user-facing language
|
||||
|
||||
- `frontend/src/pages/Automation.tsx:183` — `{r.event_type}` rendered raw (e.g.
|
||||
`vehicle.returned.v1`) in the primary ledger table, no human label.
|
||||
- `frontend/src/pages/Automation.tsx:91` — "delivered through the outbox" in primary copy.
|
||||
- `frontend/src/pages/Audit.tsx:129` — `{e.correlation_id.slice(0, 8)}` shown as the
|
||||
visible `<summary>` trigger text for technical detail, not a meaningful reference.
|
||||
- `frontend/src/pages/DataQualityIssueDetail.tsx:341,366` — "canonical odometer" used
|
||||
directly in primary decision copy, not translated to an operational phrase.
|
||||
- No central technical→human terminology mapping exists anywhere in the frontend.
|
||||
|
||||
## Demo Guide panel problems
|
||||
|
||||
- `frontend/src/components/DemoGuide.tsx` + the `@media (min-width: 701px)` rule in
|
||||
`styles.css` implement exactly **one** behaviour for every desktop/tablet width ≥701px:
|
||||
a fixed 400px right-side panel with `.app-workspace.guide-open { padding-right:
|
||||
min(400px, 92vw) }` reserving space. There is no distinction between "extra-wide
|
||||
desktop, dock + reflow with a guaranteed minimum content width" and "standard desktop/
|
||||
tablet, floating non-modal sheet that auto-collapses to a progress chip" as the brief
|
||||
now requires — today the panel never collapses to a chip at all; it only fully opens
|
||||
or fully closes.
|
||||
- "Ga naar deze stap" (`DemoGuide.tsx`'s `goToStepRoute()`) only calls `navigate(...)`; it
|
||||
does not scroll to or focus the relevant target element, and does not auto-collapse the
|
||||
panel afterward.
|
||||
- No semantic target anchors exist on pages for the guide to scroll/focus toward.
|
||||
- Reduced-motion is not explicitly handled for the panel's open/close or any future
|
||||
collapse/expand transition.
|
||||
|
||||
## Data Quality Workbench contrast and hierarchy
|
||||
|
||||
- Confirmed by direct inspection of `DataQualityIssueDetail.tsx`: only the odometer-
|
||||
regression decision (lines 349,358) uses the higher-contrast `.check-card` treatment
|
||||
(`background: var(--surface-subtle); border: 1px solid var(--line); padding: 10px 12px`).
|
||||
Every other decision point — `DuplicateCustomerPanel`'s survivor choice (134,143) and
|
||||
per-field merge choice (172,187), and `BookingOverlapPanel`'s block-choice (459) — uses
|
||||
the bare `.checkbox-label` class: a small inline radio with no card background, no
|
||||
border, no selected-state treatment, and no visible risk/consequence copy next to the
|
||||
option. This is the inconsistency the brief describes as "kleine losse radioknoppen in
|
||||
een zwak omlijnde rij."
|
||||
- Resolve/defer/reject already have some visual differentiation (`button-primary` for the
|
||||
main resolution action inside each panel; a separate "Defer or reject" section below),
|
||||
but defer and reject are rendered as two identically-weighted plain `<button>` elements
|
||||
with no secondary/tertiary visual distinction from each other.
|
||||
- No sticky/always-visible action bar exists within longer resolution panels.
|
||||
|
||||
## Automation/audit density
|
||||
|
||||
- `frontend/src/pages/Automation.tsx`'s delivery ledger renders every one of the ~20
|
||||
seeded `succeeded` events as an individual flat table row with only a status-text
|
||||
filter (`pending`/`delivering`/`succeeded`/`failed`) — no grouping/summarization of
|
||||
repeated successes, and event references are raw 8-character UUID slices with no
|
||||
meaningful short display reference scheme.
|
||||
- `frontend/src/pages/Audit.tsx` shows one row per raw audit event; my prior "View related
|
||||
events" filter (added in the demo-productization pass) lets a user filter down to a
|
||||
correlation group, but does not present that group as one collapsed operational summary
|
||||
by default — a visitor still sees N separate technical rows even after filtering.
|
||||
- Before/after is already shown as a computed diff string (`describeChanges()`), not raw
|
||||
JSON, which partially satisfies 8C already — but the diff is a flat semicolon-joined
|
||||
string, not the labelled multi-line "Status: A → B" presentation the brief shows.
|
||||
|
||||
## Rows that are not fully clickable
|
||||
|
||||
- **Attention Queue** (`Dashboard.tsx:126-146`, `.attention-list li`): only the `<Link>`
|
||||
wrapping the item's title text is interactive; the severity badge, detail text, ref,
|
||||
and chevron are inert. Confirmed by direct markup inspection.
|
||||
- **Today's movements** (`Dashboard.tsx:153-161`, `.movement-timeline li`): only the
|
||||
`<Link>` wrapping the booking reference is interactive; each row unambiguously points
|
||||
to one booking, so this qualifies for the same fully-clickable-row treatment.
|
||||
- **Recent activity** (`Dashboard.tsx:220-224`, `.recent-list li`): has **no** link at all
|
||||
today, and there is no existing single-record detail page for an individual automation
|
||||
event to point to — out of scope for "make it clickable" per the brief's own
|
||||
"alleen wanneer de volledige rij ondubbelzinnig naar één bestemming verwijst" carve-out;
|
||||
left as-is unless a natural destination is introduced elsewhere in this pass.
|
||||
- Scenario cards (`Scenarios.tsx`) already use a single full-card "Start scenario" link
|
||||
per card with no competing interactive elements inside — already compliant, no change
|
||||
needed.
|
||||
|
||||
## Plan (implementation order)
|
||||
|
||||
1. Rebrand to Fleet Ops (frontend strings, backend description strings, knowledge
|
||||
procedure prose, index.html, e2e assertions) — batch, tested, committed.
|
||||
2. i18n architecture: add `i18next`/`react-i18next`, language switcher, persistence,
|
||||
`html lang`, `Intl` formatting, namespaces, fail-fast missing-key check.
|
||||
3. Translate all existing screens + new demo surfaces into nl-BE/en-GB/fr-BE.
|
||||
4. Backend message-code fields for data-quality reasons/recommended actions/return
|
||||
reasons/integration statuses/audit actions/errors; frontend localizes.
|
||||
5. Multilingual knowledge base (5 procedures × 3 locales) + locale-aware provider lookup.
|
||||
6. Adaptive Demo Guide: docked-rail / floating-sheet+chip / mobile-bottom-sheet behaviour,
|
||||
scroll-to-target + focus + highlight, reduced motion.
|
||||
7. Data Quality Workbench: choice cards everywhere, sticky action bar, de-emphasized
|
||||
defer/reject, contrast fixes.
|
||||
8. Central terminology mapping layer (nl/en/fr) used in Automation/Audit/Data-Quality.
|
||||
9. Automation ledger grouping/filtering + retry UX narration.
|
||||
10. Audit trail human action labels + correlation-grouped summary + labelled diffs.
|
||||
11. Fully clickable Attention Queue + Today's movements rows, with tests.
|
||||
12. Responsive pass across the 7 required breakpoints × 3 languages.
|
||||
13. Full test suite additions (i18n, branding, guide, quality, queue, automation/audit,
|
||||
trilingual guided demo).
|
||||
14. Clean-checkout drill.
|
||||
15. Deploy feature branch to Unraid for final validation.
|
||||
16. Safe merge to `master` (the repository's actual main branch) + redeploy + final
|
||||
evidence at `artifacts/fleet-ops-release/final-summary.md`.
|
||||
@@ -0,0 +1,187 @@
|
||||
# Current functional audit
|
||||
|
||||
Performed 2026-08-02 against source `feat/mobilityops-functional-completion` (branched from
|
||||
`design/mobilityops-premium-ui` @ `54dc952`) and the live Unraid deployment at
|
||||
`http://192.168.10.150:1236` (same revision — see `server-baseline.md`). Findings below are
|
||||
either reproduced directly (curl against the live server, or reading the exact source lines)
|
||||
or are structural gaps confirmed against the task's own explicit requirements. Items already
|
||||
on record as accepted PoC tradeoffs (`docs/deferred.md`, `docs/01-scope-and-non-goals.md`,
|
||||
`PROJECT_STATE.md` known-limitations) are excluded — this file only lists items that are
|
||||
genuinely open.
|
||||
|
||||
## Method
|
||||
|
||||
- Full read of `backend/app/api/routers/*.py`, `backend/app/api/deps.py`,
|
||||
`backend/app/services/returns.py`, `backend/app/models/*.py`,
|
||||
`frontend/src/pages/*.tsx`, `frontend/src/components/Layout.tsx`,
|
||||
`frontend/src/context/AuthContext.tsx`, `frontend/src/App.tsx`.
|
||||
- Live curl verification against `http://192.168.10.150:1236` for the auth/role findings.
|
||||
- Cross-checked every finding against `docs/deferred.md`,
|
||||
`artifacts/final-acceptance/summary.md`, and the latest `PROJECT_STATE.md` sections to
|
||||
avoid re-flagging already-accepted tradeoffs.
|
||||
|
||||
## Findings
|
||||
|
||||
### F1 — Vehicles page renders the unfiltered array (client-side search is inert)
|
||||
|
||||
- Severity: high. Role: both. Route: `/vehicles`. Component:
|
||||
`frontend/src/pages/Vehicles.tsx`.
|
||||
- Repro: type any text into the "Search" box on the Vehicles page. The row count label
|
||||
(`{filtered.length} vehicles`) updates and the empty state correctly appears when nothing
|
||||
matches, but the `<tbody>` mapped over the raw `vehicles` array, not the computed
|
||||
`filtered` array — every original row stayed visible regardless of the search text.
|
||||
- Expected: only rows matching the search (combined with the status/attention filters)
|
||||
render.
|
||||
- Cause: `vehicles.map(...)` at the table body instead of `filtered.map(...)` (line 78 as
|
||||
originally read).
|
||||
- Reproduces locally: yes (read). Reproduces on Unraid: yes (identical bundled source,
|
||||
`source-revision` matches).
|
||||
- Fix: **applied** — render body now maps `filtered`. Regression test:
|
||||
`frontend/e2e/*.spec.ts` search-changes-rows case (added in Batch 1).
|
||||
|
||||
### F2 — Bookings page renders the unfiltered, unpaginated array
|
||||
|
||||
- Severity: high. Role: both. Route: `/bookings`. Component:
|
||||
`frontend/src/pages/Bookings.tsx`.
|
||||
- Repro: same class of bug — `visible` (filtered + sliced to 25/page) was computed and used
|
||||
for the meta line and pagination controls, but `<tbody>` mapped over the raw `bookings`
|
||||
array. All bookings rendered on every page regardless of filter or page number.
|
||||
- Cause: `bookings.map(...)` instead of `visible.map(...)`.
|
||||
- Fix: **applied** — render body now maps `visible`. Added a page-clamp effect so `page`
|
||||
cannot point past the last valid page when the filtered set shrinks (e.g. after a status
|
||||
filter reload returns fewer results than the current page implies).
|
||||
- Reproduces locally/Unraid: yes/yes.
|
||||
|
||||
### F3 — Session state is `sessionStorage`-authoritative, not server-verified
|
||||
|
||||
- Severity: high. Role: both. Component: `frontend/src/context/AuthContext.tsx`.
|
||||
- The logged-in `user` object is read from and written to `sessionStorage`
|
||||
(`mobilityops.demo-user`) directly; there is no call on app start to verify the HttpOnly
|
||||
session cookie is still valid, and `logout()` only clears local state — it never calls the
|
||||
server to invalidate the cookie. A stale/edited `sessionStorage` entry (or a cookie that
|
||||
expired server-side) will keep protected pages rendering as if authenticated until the
|
||||
first API call 401s, and even then nothing centrally redirects to `/login` (`isSessionExpired`
|
||||
helper exists in the same file but is never imported/called anywhere).
|
||||
- Live confirmation: no `GET /api/v1/demo/session` or `POST /api/v1/demo/logout` endpoint
|
||||
exists yet (`curl` returns 404 for both against the live server).
|
||||
- Fix: Batch 1 — add both endpoints server-side, make `AuthProvider` verify against
|
||||
`GET /demo/session` on load, call `POST /demo/logout` on sign-out, and centrally react to
|
||||
401s from the `api` client.
|
||||
|
||||
### F4 — Data-quality workbench has no role gate at all (list, detail, defer, reject)
|
||||
|
||||
- Severity: high. Role: Rental Employee. Routes: `/data-quality`, `/data-quality/:ref`.
|
||||
Endpoints: `GET /api/v1/data-quality/issues`, `GET /api/v1/data-quality/issues/{ref}`,
|
||||
`POST .../defer`, `POST .../reject`.
|
||||
- Live confirmation: logged in as `rental_employee` on the live server, `GET
|
||||
/api/v1/data-quality/issues` and `GET /api/v1/audit` both returned `200` (curl evidence
|
||||
above). Only `merge-customers` and `scan` are gated to Operations Manager; `defer`/`reject`
|
||||
are not, and the whole workbench is reachable and actionable by Rental Employee both via
|
||||
direct API call and via the UI (`DataQuality.tsx` has no role check at all; `Layout.tsx`
|
||||
shows the "Data quality" and "Audit trail" nav items unconditionally to both roles).
|
||||
- The task's role matrix (this brief, section 4) puts data-quality and audit entirely under
|
||||
Operations Manager — Rental Employee's list is dashboard/fleet/vehicle
|
||||
detail/bookings/booking detail/return/knowledge only. Decision recorded: tighten
|
||||
`list_issues`, `get_issue`, `defer`, `reject`, and `GET /api/v1/audit` to
|
||||
`require_operations_manager`; hide the nav items and show the same restricted-message
|
||||
pattern already used by `Automation.tsx` for direct URL access by Rental Employee.
|
||||
- Fix: Batch 1.
|
||||
|
||||
### F5 — Return preview does not exist; the review step (if any) cannot be authoritative
|
||||
|
||||
- Severity: high. Section 5 requirement. No `POST
|
||||
/api/v1/bookings/{public_ref}/return-preview` (or equivalent) endpoint exists anywhere in
|
||||
`backend/app/api/routers/bookings.py`. The frontend return flow can therefore only ever
|
||||
show a client-guessed preview, or skip a real preview step entirely.
|
||||
- Fix: Batch 2 — one authoritative evaluation function shared by preview (no writes) and
|
||||
commit.
|
||||
|
||||
### F6 — Audit API never exposes `before_json`/`after_json`
|
||||
|
||||
- Severity: medium. `AuditEvent` (`backend/app/models/audit.py`) stores `before_json` and
|
||||
`after_json`, populated by `record_audit_event` call sites (e.g. `return_registered`,
|
||||
`vehicle_status_changed`), but `AuditEventOut` (`backend/app/schemas.py`) and the router
|
||||
(`backend/app/api/routers/audit.py`) only ever return `metadata`, never before/after. The
|
||||
UI (`Audit.tsx`) therefore cannot show what changed, only that something happened.
|
||||
- Fix: Batch 2 — add `before`/`after` to `AuditEventOut`, resolve a safe entity link where
|
||||
possible, render human-readable before/after in the UI behind progressive disclosure.
|
||||
|
||||
### F7 — Data-quality issue evidence is a raw JSON dump for 4 of 5 rule types
|
||||
|
||||
- Severity: medium. `DataQualityIssueDetail.tsx`: for every rule type except
|
||||
`possible_duplicate_customer`, the only resolution surface is `<pre>{JSON.stringify(issue.evidence,
|
||||
null, 2)}</pre>` plus generic Defer/Reject buttons. `missing_required_field`,
|
||||
`odometer_regression`, `booking_overlap`, and `vehicle_status_conflict` have no typed,
|
||||
bounded resolution flow at all.
|
||||
- Fix: Batch 3.
|
||||
|
||||
### F8 — Related-snapshot typing is inferred from rule type, not explicit
|
||||
|
||||
- Severity: low. `backend/app/api/routers/data_quality.py::get_issue` infers
|
||||
`related_entity_type = "customer" if issue.rule_type == "possible_duplicate_customer" else
|
||||
"vehicle"` — a `booking_overlap` issue's related entity is actually a booking, not a
|
||||
vehicle, so its snapshot lookup silently returns `None` today. Confirmed by reading
|
||||
`_snapshot()`, which only knows how to look up `customer` or `vehicle` rows.
|
||||
- Fix: Batch 3 — typed snapshots for customer/vehicle/booking/inspection.
|
||||
|
||||
### F9 — Global search is a blind client-side regex guesser
|
||||
|
||||
- Severity: medium. `frontend/src/components/Layout.tsx::handleSearch` pattern-matches
|
||||
`MO-…`/`BK-…`/`DQ-…` and navigates without checking the entity exists, or fuzzy-matches a
|
||||
hardcoded `SEARCH_DESTINATIONS` term list. No backend search endpoint exists. No results
|
||||
panel, no keyboard navigation within results, no role filtering, no debounced live query —
|
||||
it is a single-shot form submit.
|
||||
- Fix: Batch 4 — `GET /api/v1/search`.
|
||||
|
||||
### F10 — n8n integration status is derived from the single most recent outbox event
|
||||
|
||||
- Severity: medium. `Automation.tsx` line: `<StatusBadge status={runs?.[0]?.status ??
|
||||
"no_events"} />` — the "n8n delivery" health card shows whichever status the most recent
|
||||
event happens to be in, not an aggregate of pending/delivering/failed/succeeded counts or
|
||||
dispatcher-enabled state. A single old failed event sitting behind 40 succeeded ones would
|
||||
misreport health; a single lucky success would hide an otherwise-failing dispatcher.
|
||||
- Fix: Batch 4 — truthful aggregate integration-status endpoint.
|
||||
|
||||
### F11 — Stale `delivering` outbox events have no lease/recovery
|
||||
|
||||
- Severity: medium. `backend/app/services/dispatcher.py`: `_claim_due_events` flips rows to
|
||||
`delivering` and commits before the HTTP call; if the process is killed between that commit
|
||||
and the outcome-recording transaction, the row stays `delivering` forever with no timeout
|
||||
or reclaim sweep. Not documented anywhere as an accepted limitation.
|
||||
- Fix: Batch 5 — bounded delivery lease + stale-recovery sweep.
|
||||
|
||||
### F12 — No UI trigger for demo reset
|
||||
|
||||
- Severity: low. `POST /api/v1/demo/reset` exists and is Operations-Manager-gated
|
||||
server-side, but no page exposes a "Run demo reset" action; it can currently only be
|
||||
invoked directly against the API.
|
||||
- Fix: Batch 4.
|
||||
|
||||
### F13 — No UI trigger for manual data-quality scan
|
||||
|
||||
- Severity: low. `POST /api/v1/data-quality/scan` exists and is OM-gated, but
|
||||
`DataQuality.tsx` has no "Run quality scan" button.
|
||||
- Fix: Batch 3.
|
||||
|
||||
### F14 — Second n8n workflow (scheduled quality scan) not present
|
||||
|
||||
- Severity: low, explicitly requested in this brief (section 10C). Only
|
||||
`n8n/mobilityops-return-processing.json` exists.
|
||||
- Fix: Batch 5.
|
||||
|
||||
## Not re-flagged (already-accepted, on record)
|
||||
|
||||
RAGcore/MCP Hub never live-round-tripped (environment limitation, honestly degraded);
|
||||
`Inspection.public_ref` sequence not gap-safe under concurrency; DQ idempotency key
|
||||
simplified from the doc's literal fingerprint scheme (reasoned, documented deviation); no
|
||||
optimistic `version` check on return requests (row-locking already provides the required
|
||||
concurrency safety — the doc's "optimistic version where relevant" was not adopted, and nothing in
|
||||
this brief requires adding one on top of working pessimistic locking); demo auth is an
|
||||
HMAC-cookie PoC mechanism, not a production IdP; n8n owner-account bootstrap remains a
|
||||
one-time manual step (n8n 2.x product behavior).
|
||||
|
||||
## Next step
|
||||
|
||||
Fix F1–F4 now (Batch 1, in progress), continue through F5–F14 in the batches recorded in
|
||||
`PROJECT_STATE.md` / the task list, deploying and re-verifying against Unraid after each
|
||||
batch per the brief's server-first loop.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Unraid server baseline (functional-completion audit)
|
||||
|
||||
Captured 2026-08-02 before any functional-completion changes, via `ssh unraid` (alias in
|
||||
`~/.ssh/config`, key `itworx_unraid_deploy`; the `widefrog_unraid_deploy` identity named in
|
||||
the task brief does not exist locally — the working alias/key was used instead, no new key
|
||||
was created).
|
||||
|
||||
## SSH access
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 unraid "hostname && docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
|
||||
```
|
||||
|
||||
Result: reachable, host `Tower`. `widefrog_unraid_deploy` is not a valid identity path on
|
||||
this workstation (`~/.ssh/widefrog_unraid_deploy` does not exist); the pre-existing
|
||||
`unraid` SSH config alias (`itworx_unraid_deploy` key, root@192.168.10.150:22) was used
|
||||
instead and is fully functional. No key was created, copied, or replaced.
|
||||
|
||||
## Deployed containers (Compose project `mobilityops`)
|
||||
|
||||
| Container | Image | Status | Health | Restarts | Host port |
|
||||
|---|---|---|---|---|---|
|
||||
| `mobilityops-web-1` | `mobilityops-web` | Up | healthy | 0 | `1236:80` |
|
||||
| `mobilityops-api-1` | `mobilityops-api` | Up | healthy | 0 | none (compose-network only) |
|
||||
| `mobilityops-db-1` | `postgres:16-alpine` | Up | healthy | 0 | none (compose-network only) |
|
||||
|
||||
Shared host `n8n` container (outside the `mobilityops` Compose project, pre-existing
|
||||
infrastructure): Up, healthy, `5678:5678`, on the default `bridge` network (not on
|
||||
`mobilityops_mobilityops`).
|
||||
|
||||
Deployment directory: `/mnt/user/appdata/mobilityops` (matches `docs/17-runbook.md` /
|
||||
`deploy/unraid/README.md`).
|
||||
|
||||
## Deployed revision
|
||||
|
||||
`.deploy/source-revision` on the server contains:
|
||||
|
||||
```
|
||||
54dc952915a4874fcdf14781e1c37feb0e253851
|
||||
```
|
||||
|
||||
This matches the local `design/mobilityops-premium-ui` HEAD (`54dc952`) exactly — the
|
||||
server is running the last committed premium-design revision, confirming the externally
|
||||
observed branch/revision. `.deploy/` also retains prior archives
|
||||
(`source-a737860.tar.gz`, `source-686b62f.tar.gz`, `source.tar.gz`) for rollback.
|
||||
|
||||
## Migrations
|
||||
|
||||
```bash
|
||||
docker compose -p mobilityops exec -T api python -m alembic current
|
||||
# e7b08389f47f (head)
|
||||
```
|
||||
|
||||
Matches the local repository's Alembic head (`backend/alembic/versions/`:
|
||||
`c9498525abb5_initial_schema.py` → `e7b08389f47f_idempotency_records.py`). No drift.
|
||||
|
||||
## Volumes and network
|
||||
|
||||
- `mobilityops_mobilityops-db` (Postgres data, named/persistent)
|
||||
- `mobilityops_mobilityops-n8n` (retained from an earlier bundled-n8n attempt; unused now
|
||||
that the shared host `n8n` is reused — see `compose.unraid.yaml` `bundled-n8n` profile)
|
||||
- Network: `mobilityops_mobilityops` (bridge)
|
||||
|
||||
## Environment variable names present on the server `.env`
|
||||
|
||||
(names only — no values inspected or printed)
|
||||
|
||||
`APP_SECRET`, `COMPOSE_PROJECT_NAME`, `DATABASE_URL`, `DEMO_TODAY`, `KNOWLEDGE_PROVIDER`,
|
||||
`MCP_HUB_BASE_URL`, `MCP_HUB_REGISTRATION_ENABLED`, `MCP_HUB_SERVICE_TOKEN`,
|
||||
`MCP_PROVIDER_ID`, `MOBILITYOPS_API_URL`, `MOBILITYOPS_CALLBACK_TOKEN`,
|
||||
`MOBILITYOPS_DEMO_MODE`, `MOBILITYOPS_ENV`, `MOBILITYOPS_PUBLIC_URL`, `N8N_BASE_URL`,
|
||||
`N8N_BASIC_AUTH_ACTIVE`, `N8N_BASIC_AUTH_PASSWORD`, `N8N_BASIC_AUTH_USER`,
|
||||
`N8N_ENCRYPTION_KEY`, `N8N_OWNER_EMAIL`, `N8N_OWNER_PASSWORD`, `N8N_WEBHOOK_URL`,
|
||||
`POSTGRES_DB`, `POSTGRES_PASSWORD`, `POSTGRES_USER`, `RAGCORE_API_TOKEN`,
|
||||
`RAGCORE_BASE_URL`, `RAGCORE_COLLECTION`, `RAGCORE_TENANT`, `RAGCORE_WORKSPACE`, `TZ`.
|
||||
|
||||
`diff` against the committed `.env.example` variable set: no difference — the server
|
||||
`.env` was generated from the current `.env.example` with no drift in variable names.
|
||||
|
||||
## Integration configuration (as previously documented in
|
||||
`artifacts/deployment/unraid-summary.md`, re-verified live)
|
||||
|
||||
- **n8n**: shared host instance at `http://192.168.10.150:5678`, healthy, outside the
|
||||
MobilityOps Compose project. The bundled MobilityOps `n8n` service exists only behind
|
||||
the `bundled-n8n` Compose profile and is not started.
|
||||
- **RAGcore**: `KNOWLEDGE_PROVIDER=demo` — not live-connected by design; no live RAGcore
|
||||
instance exists yet.
|
||||
- **ITWorx MCP Hub**: `MCP_HUB_REGISTRATION_ENABLED=false` — provider endpoints exist and
|
||||
are independently testable, but no live Hub round trip is claimed.
|
||||
|
||||
## Application reachability
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}\n" http://192.168.10.150:1236/
|
||||
# 200
|
||||
```
|
||||
|
||||
## Startup log scan
|
||||
|
||||
```bash
|
||||
docker compose -p mobilityops logs --tail=200 api
|
||||
```
|
||||
|
||||
No traceback, fatal, or unresolved startup error observed — only routine `GET /health`
|
||||
polling entries from the Compose healthcheck.
|
||||
|
||||
## Configuration differences vs. the committed repository
|
||||
|
||||
None found: deployed source revision, Alembic head, and `.env.example` variable set all
|
||||
match the current `design/mobilityops-premium-ui` branch exactly. This baseline was
|
||||
captured immediately before any functional-completion work began.
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// One-off tooling to capture demo-productization evidence screenshots for
|
||||
// artifacts/demo-release/final-summary.md. Not part of the regular test suite
|
||||
// (prefixed with `_` and excluded from the configured test run via testIgnore).
|
||||
|
||||
const OUT = "../artifacts/demo-release/screenshots";
|
||||
|
||||
test("capture demo-release evidence screenshots", async ({ page, request }) => {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
|
||||
await page.goto("/login");
|
||||
await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/01-demo-entry-desktop.png`, fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.screenshot({ path: `${OUT}/02-demo-entry-mobile.png` });
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(page.getByText("Probeer een demonstratiescenario")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/03-dashboard-with-scenarios.png`, fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: /Demo-gids/ }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/04-demo-guide.png` });
|
||||
await page.getByRole("button", { name: "Sluiten" }).click();
|
||||
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Review return" }).click();
|
||||
await expect(page.getByText(/Expected fleet state/)).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/05-return-preview.png`, fullPage: true });
|
||||
await page.getByRole("button", { name: "Confirm return" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/06-return-result.png`, fullPage: true });
|
||||
|
||||
await page.goto("/data-quality");
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
await page.locator(".data-table tbody tr").first().locator("a").click();
|
||||
await expect(page.getByText("What's wrong")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/07-data-quality-resolution.png`, fullPage: true });
|
||||
await page.getByRole("radio", { name: /Retain canonical/ }).check();
|
||||
await page.getByRole("button", { name: "Resolve issue" }).click();
|
||||
await expect(page.getByText(/Issue .* resolved/)).toBeVisible();
|
||||
|
||||
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
|
||||
await expect(page.getByRole("heading", { name: "Compare and merge" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/08-duplicate-customer-merge.png`, fullPage: true });
|
||||
|
||||
await page.goto("/knowledge");
|
||||
await page.getByRole("button", { name: "What must I do when a vehicle returns with damage?" }).click();
|
||||
await expect(page.getByText("Grounded in cited procedures")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/09-knowledge-assistant.png`, fullPage: true });
|
||||
|
||||
await page.goto("/automation");
|
||||
await expect(page.locator(".integration-cards")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/10-integration-status.png`, fullPage: true });
|
||||
const failedRetry = page.locator(".data-table tbody tr", { hasText: "failed" }).first();
|
||||
if (await failedRetry.count()) {
|
||||
await page.screenshot({ path: `${OUT}/11-automation-retry-before.png`, fullPage: true });
|
||||
await failedRetry.getByRole("button", { name: "Retry" }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/11-automation-retry-after.png`, fullPage: true });
|
||||
}
|
||||
|
||||
await page.goto("/audit");
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/12-audit-trail.png`, fullPage: true });
|
||||
await page.locator(".data-table tbody tr").first().getByRole("button", { name: "View related events" }).click();
|
||||
await page.screenshot({ path: `${OUT}/13-audit-related-events.png`, fullPage: true });
|
||||
|
||||
await page.goto("/about");
|
||||
await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/14-about-demo.png`, fullPage: true });
|
||||
|
||||
await page.goto("/dashboard");
|
||||
await page.getByRole("button", { name: /Synthetische demo/ }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/15-demo-badge-popover.png` });
|
||||
|
||||
// Final reset, captured, to leave the environment fully restored.
|
||||
await page.keyboard.press("Escape");
|
||||
const resetButton = page.getByRole("button", { name: "Reset demo data" });
|
||||
if (await resetButton.count()) {
|
||||
await resetButton.click();
|
||||
await expect(page.getByRole("button", { name: "Yes, reset" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/16-reset-confirm.png` });
|
||||
await page.getByRole("button", { name: "Yes, reset" }).click();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
}
|
||||
});
|
||||
@@ -14,7 +14,7 @@ test("capture the seven main pages", async ({ page, request }) => {
|
||||
await page.goto("/login");
|
||||
await page.screenshot({ path: `${OUT}/1-login.png` });
|
||||
|
||||
await page.getByRole("button", { name: "Open as Operations Manager" }).click();
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
}
|
||||
|
||||
// Verifies the "stretched link" pattern used across the Attention Queue, Today's movements,
|
||||
// Vehicles, Bookings and Data Quality tables: the whole row is one activation target, not
|
||||
// just its title/reference text, while any secondary in-row link stays independently usable.
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
test("attention queue row opens its record when clicking empty row space, not just the title", async ({ page }) => {
|
||||
const row = page.locator(".attention-list li.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const box = await row.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
// Click near the far right edge of the row -- empty space, not the title text or badge.
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
|
||||
});
|
||||
|
||||
test("attention queue row is keyboard reachable and opens on Enter", async ({ page }) => {
|
||||
const row = page.locator(".attention-list li.row-clickable").first();
|
||||
const link = row.locator(".row-link");
|
||||
await link.focus();
|
||||
await expect(link).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
|
||||
});
|
||||
|
||||
test("today's movements row opens the correct booking on click", async ({ page }) => {
|
||||
const row = page.locator(".movement-timeline li.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const box = await row.boundingBox();
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(/\/bookings\/BK-/);
|
||||
});
|
||||
|
||||
test("vehicles table row opens the vehicle detail from empty row space", async ({ page }) => {
|
||||
await page.goto("/vehicles");
|
||||
const row = page.locator(".data-table tbody tr.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const ref = (await row.locator("th").first().innerText()).split("\n")[0].trim();
|
||||
const box = await row.boundingBox();
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(new RegExp(`/vehicles/${ref}$`));
|
||||
});
|
||||
|
||||
test("bookings table row opens the booking, and the secondary vehicle link stays independently clickable", async ({ page }) => {
|
||||
await page.goto("/bookings");
|
||||
const row = page.locator(".data-table tbody tr.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const vehicleLink = row.locator(".cell-link");
|
||||
const vehicleRef = (await vehicleLink.innerText()).trim();
|
||||
|
||||
// Clicking the secondary vehicle-ref link navigates to the vehicle, not the booking --
|
||||
// it must not be swallowed by the row-spanning overlay link sitting behind it.
|
||||
await vehicleLink.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/vehicles/${vehicleRef}$`));
|
||||
|
||||
await page.goto("/bookings");
|
||||
const rowAgain = page.locator(".data-table tbody tr.row-clickable").first();
|
||||
const bookingRef = (await rowAgain.locator("th").first().innerText()).split("\n")[0].trim();
|
||||
const box = await rowAgain.boundingBox();
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(new RegExp(`/bookings/${bookingRef}$`));
|
||||
});
|
||||
|
||||
test("data quality table row opens the issue detail from empty row space", async ({ page }) => {
|
||||
await page.goto("/data-quality");
|
||||
const row = page.locator(".data-table tbody tr.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const ref = (await row.locator("th").first().innerText()).split("\n")[0].trim();
|
||||
const box = await row.boundingBox();
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(new RegExp(`/data-quality/${ref}$`));
|
||||
});
|
||||
|
||||
test("attention queue row opens the correct record on a mobile viewport tap", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/dashboard");
|
||||
const row = page.locator(".attention-list li.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const box = await row.boundingBox();
|
||||
// A real touch context needs `hasTouch`, which this shared spec file doesn't opt into;
|
||||
// a mouse click at the same mobile viewport size still exercises the same CSS layout
|
||||
// and click-target logic, since the app has no touch-specific event handling.
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
|
||||
});
|
||||
|
||||
test("data quality table row has a pointer cursor and a visible focus ring covering the whole row", async ({ page }) => {
|
||||
await page.goto("/data-quality");
|
||||
const row = page.locator(".data-table tbody tr.row-clickable").first();
|
||||
await expect(row).toHaveCSS("cursor", "pointer");
|
||||
await row.locator(".row-link").focus();
|
||||
await expect(row.locator(".row-link")).toBeFocused();
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("demo guide is usable as a mobile bottom sheet", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
|
||||
const panel = page.getByRole("dialog", { name: "Gegidste demo" });
|
||||
await expect(panel).toBeVisible();
|
||||
const box = await panel.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
// A bottom sheet: anchored to the bottom of the viewport, not a full-height side panel.
|
||||
expect(box!.height).toBeLessThan(800);
|
||||
expect(box!.x).toBeLessThanOrEqual(1);
|
||||
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await expect(page.getByRole("heading", { name: "2. Open een boeking" })).toBeVisible();
|
||||
|
||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
|
||||
});
|
||||
|
||||
test("demo guide does not cover the return form's action buttons on desktop", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
|
||||
const reviewButton = page.getByRole("button", { name: "Retour nakijken" });
|
||||
await expect(reviewButton).toBeVisible();
|
||||
await reviewButton.click({ timeout: 5000 });
|
||||
await expect(page.getByText(/Verwachte wagenparkstatus/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
const guideTrigger = page.getByRole("button", { name: /Demo-gids/ });
|
||||
await guideTrigger.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
// The guide panel itself doesn't bind Escape (it's a persistent panel, not a transient
|
||||
// popover), so close it explicitly the way a keyboard user would: activate its own
|
||||
// close control.
|
||||
await page.getByRole("button", { name: "Sluiten" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
|
||||
|
||||
const badgeTrigger = page.getByRole("button", { name: /Synthetische demo/ });
|
||||
await badgeTrigger.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeHidden();
|
||||
});
|
||||
|
||||
test("key demo pages load without console errors", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() !== "error") return;
|
||||
// The app deliberately probes GET /demo/session on every load to confirm whether a
|
||||
// session cookie is still valid (see AuthContext.tsx); a logged-out visitor's very
|
||||
// first load always logs one benign 401 for this, which the app already handles via
|
||||
// .catch() -- it is not an application error.
|
||||
if (msg.text().includes("401") && msg.text().includes("Unauthorized")) return;
|
||||
errors.push(msg.text());
|
||||
});
|
||||
page.on("pageerror", (err) => errors.push(err.message));
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/scenarios");
|
||||
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
|
||||
await page.goto("/about");
|
||||
await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
|
||||
await page.getByRole("button", { name: /Demo-gids/ }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
|
||||
expect(errors, `Unexpected console errors: ${errors.join("\n")}`).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("demo entry screen names the fictional org and never shows a password", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
|
||||
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Verken als Operations Manager" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Verken als Rental Employee" })).toBeVisible();
|
||||
await expect(page.locator('input[type="password"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("start guided demo logs in as Operations Manager and opens the guide at step 1", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
// The ?guide=start marker is a one-shot signal the dashboard strips immediately after
|
||||
// consuming it, so assert on its effect (the guide panel opens at step 1) rather than
|
||||
// the transient URL, which can already be gone by the time this assertion runs.
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page.getByText("Amelie De Ridder")).toBeVisible();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
||||
await expect(trigger).toBeVisible();
|
||||
await trigger.click();
|
||||
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeVisible();
|
||||
await expect(page.getByText(/Laatste reset:/)).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: /Over deze demo/ }).click();
|
||||
await expect(page).toHaveURL(/\/about$/);
|
||||
await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
|
||||
await expect(page.getByText("Northstar Mobility").first()).toBeVisible();
|
||||
await expect(page.getByText("Demomodus").first()).toBeVisible();
|
||||
await expect(page.getByText("Niet gekoppeld").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("badge popover closes on Escape and outside click", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
||||
await trigger.click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toBeHidden();
|
||||
|
||||
await trigger.click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.mouse.click(10, 10);
|
||||
await expect(page.getByRole("dialog")).toBeHidden();
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
expect(login.ok()).toBeTruthy();
|
||||
const reset = await request.post("/api/v1/demo/reset");
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/scenarios");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
|
||||
const cards = page.locator(".scenario-card");
|
||||
await expect(cards).toHaveCount(5);
|
||||
for (const label of ["Klaar voor demo"]) {
|
||||
await expect(page.getByText(label).first()).toBeVisible();
|
||||
}
|
||||
await expect(page.getByText("Niet beschikbaar")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("starting a scenario navigates to its fixed record", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/scenarios");
|
||||
|
||||
const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" });
|
||||
await duplicateCard.getByRole("link", { name: "Start scenario" }).click();
|
||||
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
|
||||
});
|
||||
|
||||
// The default Playwright viewport (1280x720) falls in the "standard desktop/tablet" tier
|
||||
// (see useViewportTier.ts): the guide is a floating, non-modal panel that auto-collapses
|
||||
// to a persistent progress chip the moment the visitor acts on "Ga naar deze stap".
|
||||
test("demo guide: navigating steps, jumping to a step collapses to a chip, and the chip reopens it", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await expect(page.getByRole("heading", { name: "2. Open een boeking die aandacht nodig heeft" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /6\. Stel een vraag/ }).click();
|
||||
await expect(page.getByRole("heading", { name: "6. Stel een vraag aan de procedureassistent" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(page).toHaveURL(/\/knowledge$/);
|
||||
// Panel auto-collapses to a persistent chip -- it must never sit over the knowledge
|
||||
// page's primary "Ask" action after navigation.
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
|
||||
const chip = page.getByRole("button", { name: /Demo-gids · stap 6 van 8/ });
|
||||
await expect(chip).toBeVisible();
|
||||
|
||||
await chip.click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "6. Stel een vraag aan de procedureassistent" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the collapsed chip has its own close control, independent of reopening it", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
const chip = page.getByRole("button", { name: /Demo-gids · stap/ });
|
||||
await expect(chip).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Sluiten" }).click();
|
||||
await expect(chip).toBeHidden();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
|
||||
});
|
||||
|
||||
test("demo guide progress persists across navigation and the trigger shows it", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await page.getByRole("button", { name: "Demo-gids inklappen" }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: /^Demo-gids/ }).first()).toContainText("2/8");
|
||||
|
||||
await page.goto("/vehicles");
|
||||
await page.getByRole("button", { name: /^Demo-gids/ }).first().click();
|
||||
await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("demo guide is not shown to a rental employee", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Rental Employee" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("restarting the demo from the guide resets data and returns to login", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Demo opnieuw voorbereiden" }).click();
|
||||
await expect(page).toHaveURL(/\/login$/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test("wide desktop viewport docks the guide as a rail that never collapses to a chip", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
const panel = page.locator(".demo-guide-panel.is-wide");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.locator(".demo-guide-chip")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("mobile viewport shows a bottom sheet with collapsed/half/full states and no horizontal overflow", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
const panel = page.locator(".demo-guide-panel.is-mobile");
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toHaveClass(/sheet-half/);
|
||||
|
||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
|
||||
|
||||
await page.locator(".demo-guide-sheet-handle").click();
|
||||
await expect(panel).toHaveClass(/sheet-full/);
|
||||
await page.locator(".demo-guide-sheet-handle").click();
|
||||
await expect(panel).toHaveClass(/sheet-collapsed/);
|
||||
});
|
||||
|
||||
test("Escape collapses the standard-tier panel, then closes it", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: /Demo-gids · stap/ })).toBeVisible();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: /Demo-gids · stap/ })).toBeHidden();
|
||||
});
|
||||
|
||||
test("going to a step scrolls, focuses and highlights the on-page target", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await page.getByRole("button", { name: /6\. Stel een vraag/ }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(page).toHaveURL(/\/knowledge$/);
|
||||
|
||||
const target = page.locator("#ask-heading");
|
||||
await expect(target).toBeFocused();
|
||||
await expect(target).toHaveClass(/demo-guide-highlight/);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
expect(login.ok()).toBeTruthy();
|
||||
const reset = await request.post("/api/v1/demo/reset");
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
|
||||
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
|
||||
const odometerInput = page.getByLabel("Eindkilometerstand (km)");
|
||||
await expect(odometerInput).not.toHaveValue("");
|
||||
const prefilled = Number(await odometerInput.inputValue());
|
||||
expect(prefilled).toBeGreaterThan(0);
|
||||
|
||||
await page.getByRole("button", { name: "Retour nakijken" }).click();
|
||||
await expect(page.getByText(/laatst bevestigde stand/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Retour bevestigen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Audit trail bekijken" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
|
||||
|
||||
await expect(page.getByText("Wat is er mis")).toBeVisible();
|
||||
await expect(page.getByText("Waarom dit belangrijk is")).toBeVisible();
|
||||
await expect(page.getByText(/waarschijnlijk dezelfde persoon/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality list can filter to demo scenarios only", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/data-quality");
|
||||
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const allRows = await page.locator(".data-table tbody tr").count();
|
||||
await page.getByRole("checkbox", { name: "Enkel demoscenario's" }).check();
|
||||
const filteredRows = await page.locator(".data-table tbody tr").count();
|
||||
expect(filteredRows).toBeGreaterThan(0);
|
||||
expect(filteredRows).toBeLessThanOrEqual(allRows);
|
||||
const refs = await page.locator(".data-table tbody tr th a").allTextContents();
|
||||
for (const ref of refs) {
|
||||
expect(ref.startsWith("DQ-DEMO-")).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/knowledge");
|
||||
|
||||
// The status badge and retrieval-flow diagram must name the actual active provider
|
||||
// honestly, not the not-yet-connected "RAGcore" -- the honest disclosure note below is
|
||||
// allowed to mention RAGcore by name when explaining it isn't live yet.
|
||||
await expect(page.locator(".knowledge-status strong")).toHaveText("de demokennisbank");
|
||||
await expect(page.locator(".retrieval-flow")).toContainText("de demokennisbank");
|
||||
await expect(page.locator(".knowledge-status")).not.toContainText("RAGcore");
|
||||
|
||||
await page.getByRole("button", { name: "Wie beoordeelt een ongewone kilometerstand?" }).click();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
|
||||
});
|
||||
@@ -1,13 +1,11 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const API_BASE = process.env.MOBILITYOPS_API_URL ?? "http://localhost:8128";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
const login = await request.post(`${API_BASE}/api/v1/demo/login`, {
|
||||
const login = await request.post("/api/v1/demo/login", {
|
||||
data: { role: "operations_manager" },
|
||||
});
|
||||
expect(login.ok()).toBeTruthy();
|
||||
const reset = await request.post(`${API_BASE}/api/v1/demo/reset`);
|
||||
const reset = await request.post("/api/v1/demo/reset");
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
@@ -20,13 +18,13 @@ test("five-minute demo script end to end", async ({ page, request }) => {
|
||||
|
||||
await test.step("1. login as Operations Manager", async () => {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByText(/Synthetic proof of concept/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Open as Operations Manager" }).click();
|
||||
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
await test.step("2. verify dashboard metrics are loaded", async () => {
|
||||
await expect(page.getByRole("heading", { name: "Fleet readiness" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
|
||||
const metricValues = page.locator(".metric-cell dd");
|
||||
await expect(metricValues.first()).toBeVisible();
|
||||
const values = await metricValues.allTextContents();
|
||||
@@ -37,62 +35,61 @@ test("five-minute demo script end to end", async ({ page, request }) => {
|
||||
await test.step("3. open active demo booking", async () => {
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
|
||||
await expect(page.getByText("active", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("actief", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("4. register an odometer-regression return (S1)", async () => {
|
||||
const vehicleOdometerText = await page
|
||||
.locator(".detail-grid div", { hasText: "Start odometer" })
|
||||
.locator(".detail-grid div", { hasText: "Startkilometerstand" })
|
||||
.locator("dd")
|
||||
.textContent();
|
||||
const startOdometer = parseInt((vehicleOdometerText ?? "0").replace(/\D/g, ""), 10);
|
||||
const lowReading = Math.max(0, startOdometer - 500);
|
||||
|
||||
await page.getByLabel("End odometer (km)").fill(String(lowReading));
|
||||
await page.getByLabel("Fuel level (%)").fill("55");
|
||||
await page.getByRole("button", { name: "Review return" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Review return impact" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Confirm return" }).click();
|
||||
await page.getByLabel("Eindkilometerstand (km)").fill(String(lowReading));
|
||||
await page.getByLabel("Brandstofniveau (%)").fill("55");
|
||||
await page.getByRole("button", { name: "Retour nakijken" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Retourimpact nakijken" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Retour bevestigen" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("5. verify quality issue and queued automation event", async () => {
|
||||
await expect(page.getByText(/DQ-RET-|None created/)).toBeVisible();
|
||||
await expect(page.getByText(/Queued \(/)).toBeVisible();
|
||||
await expect(page.getByText(/DQ-RET-|Geen aangemaakt/)).toBeVisible();
|
||||
await expect(page.getByText(/Klaargezet voor verwerking \(/)).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("6. resolve the duplicate customer scenario (S2)", async () => {
|
||||
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
|
||||
await expect(page.getByRole("heading", { name: "Compare and merge" })).toBeVisible();
|
||||
await page.getByRole("button", { name: /Merge into CUS-0012/ }).click();
|
||||
await page.getByRole("button", { name: "Yes, merge" }).click();
|
||||
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Vergelijken en samenvoegen" })).toBeVisible();
|
||||
await page.getByRole("button", { name: /Samenvoegen met CUS-0012/ }).click();
|
||||
await page.getByRole("button", { name: "Ja, samenvoegen" }).click();
|
||||
await expect(page.locator(".badge.status-resolved")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("7. ask the damage question and inspect citations (S6)", async () => {
|
||||
await page.goto("/knowledge");
|
||||
await page
|
||||
.getByPlaceholder(/What must I do when a vehicle returns with damage/)
|
||||
.fill("What must I do when a vehicle returns with damage?");
|
||||
await page.getByRole("button", { name: "Ask" }).click();
|
||||
await expect(page.getByText("Grounded in cited procedures")).toBeVisible();
|
||||
await expect(page.getByText("Damage handling procedure").first()).toBeVisible();
|
||||
await expect(page.getByText("Vehicle return procedure").first()).toBeVisible();
|
||||
.getByPlaceholder(/Wat moet ik doen wanneer een voertuig beschadigd terugkomt/)
|
||||
.fill("Wat moet ik doen wanneer een voertuig terugkomt met schade?");
|
||||
await page.getByRole("button", { name: "Vraag stellen" }).click();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
|
||||
await expect(page.getByText("Procedure schadeafhandeling").first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("8. inspect audit entries", async () => {
|
||||
await page.goto("/audit");
|
||||
await page.getByLabel("Action").fill("return_registered");
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
await expect(page.getByText("return registered").first()).toBeVisible();
|
||||
await page.getByLabel("Actie").fill("return_registered");
|
||||
await expect(page.locator(".audit-group-list li").first()).toBeVisible();
|
||||
await expect(page.getByText("Voertuigretour geregistreerd").first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("9. verify responsive navigation at mobile width", async () => {
|
||||
await page.setViewportSize({ width: 360, height: 800 });
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByText(/Synthetic demo data/).first()).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Overview" }).first()).toBeVisible();
|
||||
await expect(page.getByText(/Synthetische demo/).first()).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Overzicht" }).first()).toBeVisible();
|
||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
expect(login.ok()).toBeTruthy();
|
||||
const reset = await request.post("/api/v1/demo/reset");
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("full guided demo walkthrough, start to finish, restoring the environment after", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
// Wide desktop viewport: the guide docks as a rail and never auto-collapses to a chip
|
||||
// (see useViewportTier.ts), so this walkthrough can keep interacting with the panel
|
||||
// directly across every step -- the standard-tier auto-collapse behaviour itself is
|
||||
// covered separately in demo-guide.spec.ts.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
|
||||
await test.step("start the guided demo from the login screen", async () => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("step 1: understand the operational state", async () => {
|
||||
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 2: open the booking needing attention", async () => {
|
||||
await expect(page.getByRole("heading", { name: "2. Open een boeking" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(page).toHaveURL(/\/bookings\/BK-DEMO-RETURN$/);
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 3: process the return with the pre-filled odometer anomaly", async () => {
|
||||
await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible();
|
||||
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Retour nakijken" }).click();
|
||||
await expect(page.getByText(/laatst bevestigde stand/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Retour bevestigen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga verder met de demo" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 4: handle the newly created data-quality issue", async () => {
|
||||
await expect(page).toHaveURL(/\/data-quality$/);
|
||||
await expect(page.getByRole("heading", { name: "4. Bekijk en behandel" })).toBeVisible();
|
||||
const firstIssueLink = page.locator(".data-table tbody tr").first().locator("a");
|
||||
await firstIssueLink.click();
|
||||
await expect(page.getByText("Wat is er mis")).toBeVisible();
|
||||
// The newest issue is the odometer regression this return just created.
|
||||
await page.getByRole("radio", { name: /Laatst bevestigde stand behouden/ }).check();
|
||||
await page.getByRole("button", { name: "Probleem oplossen" }).click();
|
||||
await expect(page.getByText(/Probleem .* opgelost/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga verder met de demo" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 5: review and merge the possible duplicate customer", async () => {
|
||||
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
|
||||
await expect(page.getByRole("heading", { name: "5. Beoordeel en behandel" })).toBeVisible();
|
||||
await page.getByRole("button", { name: /^Samenvoegen met/ }).click();
|
||||
await page.getByRole("button", { name: "Ja, samenvoegen" }).click();
|
||||
await expect(page.getByText(/Probleem .* opgelost/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga verder met de demo" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 6: ask the procedure assistant a question", async () => {
|
||||
await expect(page).toHaveURL(/\/knowledge$/);
|
||||
await expect(page.getByRole("heading", { name: "6. Stel een vraag" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Wat moet ik doen wanneer een voertuig terugkomt met schade?" }).click();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 7: check automation and the audit trail", async () => {
|
||||
await expect(page.getByRole("heading", { name: "7. Controleer automatisering" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(page).toHaveURL(/\/automation$/);
|
||||
// Plain-language status only -- never the raw backend state string (e.g. "degraded").
|
||||
await expect(page.locator(".integration-cards")).not.toContainText("degraded");
|
||||
await expect(page.locator(".integration-cards")).not.toContainText("no_evidence");
|
||||
await page.goto("/audit");
|
||||
await expect(page.locator(".audit-group-list li").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: /Demo-gids/ }).click();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 8: review what's real, simulated, or not yet connected", async () => {
|
||||
await expect(page.getByRole("heading", { name: "8. Bekijk wat echt is" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(page).toHaveURL(/\/about$/);
|
||||
await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
|
||||
await expect(page.getByText("Demomodus", { exact: false }).first()).toBeVisible();
|
||||
await expect(page.getByText("Niet gekoppeld").first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("restore the environment", async () => {
|
||||
await resetDemoData(request);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Pure Node-context checks (no browser needed): every locale must define exactly the
|
||||
// same set of translation keys. A missing key would otherwise silently fall back to
|
||||
// showing the raw key string in production -- this test makes that impossible to ship.
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const LOCALES_DIR = path.resolve(__dirname, "../src/i18n/locales");
|
||||
const LANGUAGES = ["nl-BE", "en-GB", "fr-BE"];
|
||||
|
||||
function collectKeyPaths(value: unknown, prefix = ""): string[] {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return [prefix];
|
||||
}
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
|
||||
collectKeyPaths(nested, prefix ? `${prefix}.${key}` : key),
|
||||
);
|
||||
}
|
||||
|
||||
function loadNamespace(language: string, namespace: string): Record<string, unknown> {
|
||||
const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`);
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
const namespaces = fs
|
||||
.readdirSync(path.join(LOCALES_DIR, "nl-BE"))
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.replace(/\.json$/, ""));
|
||||
|
||||
test("every locale defines the same translation keys as nl-BE, for every namespace", () => {
|
||||
expect(namespaces.length).toBeGreaterThan(0);
|
||||
|
||||
for (const namespace of namespaces) {
|
||||
const referenceKeys = collectKeyPaths(loadNamespace("nl-BE", namespace)).sort();
|
||||
|
||||
for (const language of LANGUAGES) {
|
||||
if (language === "nl-BE") continue;
|
||||
const keys = collectKeyPaths(loadNamespace(language, namespace)).sort();
|
||||
const missing = referenceKeys.filter((k) => !keys.includes(k));
|
||||
const extra = keys.filter((k) => !referenceKeys.includes(k));
|
||||
|
||||
expect(
|
||||
missing,
|
||||
`${language}/${namespace}.json is missing keys present in nl-BE: ${missing.join(", ")}`,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
extra,
|
||||
`${language}/${namespace}.json has extra keys not present in nl-BE: ${extra.join(", ")}`,
|
||||
).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("no locale file contains an empty string value", () => {
|
||||
for (const language of LANGUAGES) {
|
||||
for (const namespace of namespaces) {
|
||||
const data = loadNamespace(language, namespace);
|
||||
const keys = collectKeyPaths(data);
|
||||
for (const keyPath of keys) {
|
||||
const value = keyPath.split(".").reduce<unknown>((acc, part) => {
|
||||
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
|
||||
return undefined;
|
||||
}, data);
|
||||
if (typeof value === "string") {
|
||||
expect(value.trim().length, `${language}/${namespace}.json:${keyPath} is empty`).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,17 +1,19 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
await request.post("http://localhost:8128/api/v1/demo/login", {
|
||||
data: { role: "operations_manager" },
|
||||
});
|
||||
await request.post("http://localhost:8128/api/v1/demo/reset");
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
// This file's assertions were authored against the English UI copy; nl-BE is now the
|
||||
// app's default for a fresh session, so force English explicitly rather than rewriting
|
||||
// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs).
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Open as Operations Manager" }).click();
|
||||
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
@@ -48,6 +50,28 @@ test("vehicles page: status filter and attention-only checkbox both work", async
|
||||
expect(attentionCells.every((c) => c.includes("Needs attention"))).toBeTruthy();
|
||||
});
|
||||
|
||||
test("vehicles page: free-text search actually filters the rendered rows", async ({ page }) => {
|
||||
await page.goto("/vehicles");
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
const totalRows = await page.locator(".data-table tbody tr").count();
|
||||
expect(totalRows).toBeGreaterThan(1);
|
||||
|
||||
const searchBox = page.getByRole("form", { name: "Vehicle fleet" }).getByLabel("Search");
|
||||
await searchBox.fill("MO-001");
|
||||
await expect(async () => {
|
||||
const rows = await page.locator(".data-table tbody tr").count();
|
||||
expect(rows).toBe(1);
|
||||
}).toPass({ timeout: 5000 });
|
||||
const refs = await page.locator(".data-table tbody tr th a").allTextContents();
|
||||
expect(refs).toEqual(["MO-001"]);
|
||||
|
||||
await searchBox.fill("");
|
||||
await expect(async () => {
|
||||
const rows = await page.locator(".data-table tbody tr").count();
|
||||
expect(rows).toBe(totalRows);
|
||||
}).toPass({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("vehicle detail: all tabs render distinct content", async ({ page }) => {
|
||||
await page.goto("/vehicles/MO-016");
|
||||
await expect(page.getByRole("heading", { name: /MO-016/ })).toBeVisible();
|
||||
@@ -66,6 +90,51 @@ test("bookings page: status filter works", async ({ page }) => {
|
||||
expect(statuses.every((s) => s.includes("returned"))).toBeTruthy();
|
||||
});
|
||||
|
||||
test("bookings page: pagination renders at most 25 rows and page 2 differs from page 1", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/bookings");
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
|
||||
const page1Count = await page.locator(".data-table tbody tr").count();
|
||||
expect(page1Count).toBeLessThanOrEqual(25);
|
||||
const page1Refs = await page.locator(".data-table tbody tr th a").allTextContents();
|
||||
|
||||
const nextButton = page.getByRole("button", { name: "Next" });
|
||||
await expect(nextButton).toBeEnabled();
|
||||
await nextButton.click();
|
||||
|
||||
await expect(async () => {
|
||||
const page2Refs = await page.locator(".data-table tbody tr th a").allTextContents();
|
||||
expect(page2Refs.length).toBeGreaterThan(0);
|
||||
expect(page2Refs).not.toEqual(page1Refs);
|
||||
}).toPass({ timeout: 5000 });
|
||||
|
||||
const page2Count = await page.locator(".data-table tbody tr").count();
|
||||
expect(page2Count).toBeLessThanOrEqual(25);
|
||||
|
||||
const prevButton = page.getByRole("button", { name: "Previous" });
|
||||
await expect(prevButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("return preview correctly reports blocked (not maintenance) for damage reported", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
await page.getByLabel("End odometer (km)").fill("55000");
|
||||
await page.getByLabel("Fuel level (%)").fill("40");
|
||||
await page.getByRole("checkbox", { name: "Damage reported" }).check();
|
||||
await page.getByRole("button", { name: "Review return" }).click();
|
||||
|
||||
// The preview is the server's authoritative evaluation: damage always routes to
|
||||
// "blocked", never "maintenance" -- this used to be guessed client-side and wrong.
|
||||
await expect(page.getByText("Damage was reported on return.")).toBeVisible();
|
||||
const statusRegion = page.locator(".impact-preview");
|
||||
await expect(statusRegion.getByText("blocked", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality page: status and rule-type filters work", async ({ page }) => {
|
||||
await page.goto("/data-quality");
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
@@ -75,7 +144,7 @@ test("data quality page: status and rule-type filters work", async ({ page }) =>
|
||||
);
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const rules = await page.locator(".data-table tbody tr td:nth-child(2)").allTextContents();
|
||||
expect(rules.every((r) => r.includes("possible duplicate customer"))).toBeTruthy();
|
||||
expect(rules.every((r) => r.includes("Possible duplicate customer"))).toBeTruthy();
|
||||
|
||||
await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption("");
|
||||
await page.getByRole("combobox", { name: "Status", exact: true }).selectOption("resolved");
|
||||
@@ -94,7 +163,77 @@ test("data quality issue detail: defer and reject buttons work", async ({ page,
|
||||
await firstLink.click();
|
||||
await expect(page.getByRole("heading", { name: ref ?? "" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Defer" }).click();
|
||||
await expect(page.getByText("deferred", { exact: true })).toBeVisible();
|
||||
await expect(page.locator(".badge.status-deferred")).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: providing missing fields resolves a vehicle issue", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality/DQ-DEMO-ATTENTION");
|
||||
await expect(page.getByRole("heading", { name: "DQ-DEMO-ATTENTION" })).toBeVisible();
|
||||
|
||||
await page.getByLabel("Registration number").fill("TST-777");
|
||||
await page.getByLabel("Make").fill("TestMake");
|
||||
await page.getByLabel("Model").fill("TestModel");
|
||||
await page.getByLabel("Location").fill("Depot");
|
||||
await page.getByRole("button", { name: "Save and re-check" }).click();
|
||||
|
||||
await expect(page.getByText("Resolved").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: resolving a booking overlap blocks one booking", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
|
||||
await expect(page.getByRole("heading", { name: "DQ-DEMO-OVERLAP" })).toBeVisible();
|
||||
|
||||
await page.getByRole("radio", { name: /Block BK-DEMO-OVERLAP-A/ }).check();
|
||||
await page.getByRole("button", { name: /^Block BK-DEMO-OVERLAP-A$/ }).click();
|
||||
|
||||
await expect(page.getByText("Resolved").first()).toBeVisible();
|
||||
const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A");
|
||||
expect((await booking.json()).status).toBe("blocked");
|
||||
});
|
||||
|
||||
test("data quality: applying the recommended status resolves a vehicle conflict", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||
await expect(page.getByRole("heading", { name: "DQ-DEMO-STATUS" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Calculate and apply recommended status" }).click();
|
||||
await page.getByRole("button", { name: "Yes, apply" }).click();
|
||||
|
||||
await expect(page.getByText("Applied", { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: retaining canonical resolves an odometer regression issue", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
const issues = await (
|
||||
await page.request.get("/api/v1/data-quality/issues", {
|
||||
params: { rule_type: "odometer_regression", status: "open" },
|
||||
})
|
||||
).json();
|
||||
const target = issues[0];
|
||||
|
||||
await page.goto(`/data-quality/${target.public_ref}`);
|
||||
await expect(page.getByRole("heading", { name: target.public_ref })).toBeVisible();
|
||||
await page.getByRole("radio", { name: /Retain canonical/ }).check();
|
||||
await page.getByRole("button", { name: "Resolve issue" }).click();
|
||||
|
||||
await expect(page.getByText("Resolved").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: manual scan runs and shows a result summary", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality");
|
||||
await page.getByRole("button", { name: "Run quality scan" }).click();
|
||||
await page.getByRole("button", { name: "Yes, run scan" }).click();
|
||||
|
||||
await expect(page.getByText(/Scan complete/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("automation page: status filter and retry button work", async ({ page, request }) => {
|
||||
@@ -120,11 +259,43 @@ test("automation page: status filter and retry button work", async ({ page, requ
|
||||
|
||||
test("audit page: action filter works", async ({ page }) => {
|
||||
await page.goto("/audit");
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
await expect(page.locator(".audit-group-list")).toBeVisible();
|
||||
await page.getByLabel("Action").fill("demo_login");
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const actions = await page.locator(".data-table tbody tr td:nth-child(3)").allTextContents();
|
||||
expect(actions.every((a) => a.includes("demo login"))).toBeTruthy();
|
||||
await expect(page.locator(".audit-group").first()).toBeVisible();
|
||||
const headings = await page.locator(".audit-group-heading strong").allTextContents();
|
||||
expect(headings.every((a) => a === "Logged in")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("audit page: shows human-readable before/after and a safe entity link", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
// demo/reset deletes the acting session's own cookie, so submit the return through
|
||||
// page.request instead -- it shares the browser context's still-valid OM session from
|
||||
// beforeEach rather than the now-logged-out standalone `request` fixture.
|
||||
const submitted = await page.request.post("/api/v1/bookings/BK-DEMO-RETURN/return", {
|
||||
data: {
|
||||
end_odometer_km: 60000,
|
||||
fuel_level_percent: 55,
|
||||
cleanliness_ok: true,
|
||||
damage_reported: false,
|
||||
technical_warning: false,
|
||||
},
|
||||
headers: { "Idempotency-Key": "e2e-audit-before-after-check" },
|
||||
});
|
||||
expect(submitted.ok()).toBeTruthy();
|
||||
|
||||
await page.goto("/audit");
|
||||
await page.getByLabel("Action").fill("return_registered");
|
||||
const firstGroup = page.locator(".audit-group").first();
|
||||
await expect(firstGroup).toBeVisible();
|
||||
|
||||
const changeDiff = firstGroup.locator(".change-diff");
|
||||
await expect(changeDiff).toContainText(/status/i);
|
||||
await expect(changeDiff).toContainText("returned");
|
||||
|
||||
await expect(firstGroup.locator(".audit-group-meta a")).toHaveAttribute("href", /\/bookings\/BK-/);
|
||||
});
|
||||
|
||||
test("knowledge page: form submits and clears input", async ({ page }) => {
|
||||
@@ -141,14 +312,98 @@ test("switch role button logs out and returns to login", async ({ page }) => {
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
});
|
||||
|
||||
test("rental employee role sees restricted automation page and cannot access reset", async ({
|
||||
test("session survives a page refresh and restores the correct role", async ({ page }) => {
|
||||
await page.goto("/vehicles");
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/vehicles$/);
|
||||
await expect(page.getByText("Operations manager")).toBeVisible();
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
});
|
||||
|
||||
test("logout invalidates the server session so a refresh returns to login", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.getByRole("button", { name: "Switch role" }).click();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
|
||||
// Directly re-requesting a protected route after logout must not restore access from a
|
||||
// stale client cache; the server-side cookie is gone.
|
||||
await page.goto("/dashboard");
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
});
|
||||
|
||||
test("direct navigation to a protected route without a session redirects to login", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.clearCookies();
|
||||
await page.goto("/vehicles");
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
});
|
||||
|
||||
test("rental employee role has a restricted nav and cannot reach manager-only pages", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: "Switch role" }).click();
|
||||
await page.getByRole("button", { name: "Open as Rental Employee" }).click();
|
||||
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.getByRole("link", { name: "Integrations" }).click();
|
||||
|
||||
// Manager-only nav items are not shown at all, not merely disabled.
|
||||
await expect(page.getByRole("link", { name: "Data quality" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Integrations" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Audit trail" })).toHaveCount(0);
|
||||
|
||||
// Direct URL navigation is still blocked server-side and shows the same restricted
|
||||
// message as a defense-in-depth measure, not just a hidden button.
|
||||
await page.goto("/automation");
|
||||
await expect(
|
||||
page.getByText("Automation delivery status is visible to Operations Managers only."),
|
||||
page.getByText("Automation is visible to Operations Managers only.").first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto("/data-quality");
|
||||
await expect(
|
||||
page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only.").first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto("/audit");
|
||||
await expect(page.getByText("Audit history is visible to Operations Managers only.").first()).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Reset demo data" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("operations manager can reset demo data and is returned to login", async ({ page }) => {
|
||||
await expect(page.getByRole("button", { name: "Reset demo data" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Reset demo data" }).click();
|
||||
await page.getByRole("button", { name: "Yes, reset" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
|
||||
// The reset must not have affected the ability to log back in against fresh data.
|
||||
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
test("automation page shows the aggregate n8n integration status, not just the latest event", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/automation");
|
||||
await expect(page.locator(".integration-cards")).toContainText("succeeded");
|
||||
await expect(page.locator(".integration-cards")).toContainText("failed");
|
||||
});
|
||||
|
||||
test("rental employee direct API access to manager-only endpoints is rejected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: "Switch role" }).click();
|
||||
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
// page.request shares the browser context's cookies, and (via the web container's
|
||||
// nginx /api/ proxy) works identically against localhost and the deployed server --
|
||||
// the backend API itself is never exposed directly on either.
|
||||
for (const path of ["/api/v1/data-quality/issues", "/api/v1/audit", "/api/v1/workflows"]) {
|
||||
const response = await page.request.get(path);
|
||||
expect(response.status(), path).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
}
|
||||
|
||||
// Cross-checks the responsive shell across the exact breakpoint matrix from the polish
|
||||
// brief (1440x1000, 1280x800, 1024x768, 768x1024, 430x932, 390x844, 360x800) in all three
|
||||
// supported languages -- both that nothing overflows horizontally and that the localized
|
||||
// heading text still renders (rather than being silently truncated away).
|
||||
const BREAKPOINTS: { width: number; height: number }[] = [
|
||||
{ width: 1440, height: 1000 },
|
||||
{ width: 1280, height: 800 },
|
||||
{ width: 1024, height: 768 },
|
||||
{ width: 768, height: 1024 },
|
||||
{ width: 430, height: 932 },
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 360, height: 800 },
|
||||
];
|
||||
|
||||
const LANGUAGES: { code: string; attentionTitle: string }[] = [
|
||||
{ code: "nl-BE", attentionTitle: "Aandachtspunten" },
|
||||
{ code: "en-GB", attentionTitle: "Attention queue" },
|
||||
{ code: "fr-BE", attentionTitle: "File d'attention" },
|
||||
];
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await resetDemoData(request);
|
||||
});
|
||||
|
||||
for (const language of LANGUAGES) {
|
||||
test.describe(`${language.code}`, () => {
|
||||
for (const bp of BREAKPOINTS) {
|
||||
test(`no horizontal overflow at ${bp.width}x${bp.height}`, async ({ page }) => {
|
||||
await page.addInitScript(
|
||||
(lang) => localStorage.setItem("fleetops.language", lang),
|
||||
language.code,
|
||||
);
|
||||
await page.setViewportSize({ width: bp.width, height: bp.height });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: /begeleide demo|guided demo|démo guidée/i }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
|
||||
await expect(page.getByRole("heading", { name: language.attentionTitle })).toBeVisible();
|
||||
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
client: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(dimensions.scroll, `${language.code} @ ${bp.width}x${bp.height}`).toBeLessThanOrEqual(
|
||||
dimensions.client + 1,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const API_BASE = process.env.MOBILITYOPS_API_URL ?? "http://localhost:8128";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
await request.post(`${API_BASE}/api/v1/demo/login`, { data: { role: "operations_manager" } });
|
||||
await request.post(`${API_BASE}/api/v1/demo/reset`);
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
}
|
||||
|
||||
// This file's assertions were authored against the English UI copy; nl-BE is now the
|
||||
// app's default for a fresh session, so force English explicitly rather than rewriting
|
||||
// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs).
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Open as Operations Manager" }).click();
|
||||
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
@@ -22,30 +24,70 @@ test("control-centre shell exposes landmarks, persisted readiness and active nav
|
||||
await expect(page.getByRole("link", { name: "Overview" }).first()).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
test("global search supports its keyboard shortcut and public references", async ({ page }) => {
|
||||
test("global search supports its keyboard shortcut and finds a vehicle by reference", async ({ page }) => {
|
||||
await page.keyboard.press("Control+k");
|
||||
const search = page.getByRole("searchbox", { name: "Search MobilityOps" });
|
||||
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
|
||||
await expect(search).toBeFocused();
|
||||
await search.fill("MO-024");
|
||||
await search.press("Enter");
|
||||
const result = page.getByRole("option", { name: /MO-024/ });
|
||||
await expect(result).toBeVisible();
|
||||
await result.click();
|
||||
await expect(page).toHaveURL(/\/vehicles\/MO-024$/);
|
||||
await expect(page.getByRole("heading", { name: "MO-024" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("global search supports arrow-key navigation and Enter to select", async ({ page }) => {
|
||||
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
|
||||
await search.fill("fleet");
|
||||
await expect(page.getByRole("option", { name: /Fleet/ })).toBeVisible();
|
||||
await search.press("ArrowDown");
|
||||
await search.press("Enter");
|
||||
await expect(page).toHaveURL(/\/vehicles$/);
|
||||
});
|
||||
|
||||
test("global search shows a no-results state and closes on Escape", async ({ page }) => {
|
||||
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
|
||||
await search.fill("zzz-nothing-matches-zzz");
|
||||
await expect(page.getByText(/No matches for/)).toBeVisible();
|
||||
await search.press("Escape");
|
||||
await expect(page.getByRole("listbox")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("global search finds a booking and a data-quality issue by reference", async ({ page }) => {
|
||||
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
|
||||
await search.fill("BK-DEMO-RETURN");
|
||||
const bookingResult = page.getByRole("option", { name: /BK-DEMO-RETURN/ });
|
||||
await expect(bookingResult).toBeVisible();
|
||||
await bookingResult.click();
|
||||
await expect(page).toHaveURL(/\/bookings\/BK-DEMO-RETURN$/);
|
||||
|
||||
await search.fill("DQ-DEMO-OVERLAP");
|
||||
const issueResult = page.getByRole("option", { name: /DQ-DEMO-OVERLAP/ });
|
||||
await expect(issueResult).toBeVisible();
|
||||
await issueResult.click();
|
||||
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-OVERLAP$/);
|
||||
});
|
||||
|
||||
test("return review separates capture from irreversible commit", async ({ page }) => {
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
await page.getByLabel("End odometer (km)").fill("60000");
|
||||
await page.getByLabel("Fuel level (%)").fill("65");
|
||||
|
||||
let returnRequests = 0;
|
||||
let commitRequests = 0;
|
||||
let previewRequests = 0;
|
||||
page.on("request", (request) => {
|
||||
if (request.url().includes("/return") && request.method() === "POST") returnRequests += 1;
|
||||
if (request.method() !== "POST") return;
|
||||
if (request.url().endsWith("/return-preview")) previewRequests += 1;
|
||||
else if (request.url().endsWith("/return")) commitRequests += 1;
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Review return" }).click();
|
||||
expect(returnRequests).toBe(0);
|
||||
await expect(page.getByText("Expected fleet state")).toBeVisible();
|
||||
await expect(page.getByText("Queue n8n delivery after the local commit")).toBeVisible();
|
||||
await expect(page.getByText(/Expected fleet state/)).toBeVisible();
|
||||
expect(commitRequests).toBe(0);
|
||||
// The review step is server-evaluated (not client-guessed), so exactly one non-mutating
|
||||
// preview call is expected before any commit.
|
||||
expect(previewRequests).toBe(1);
|
||||
await expect(page.getByText("Queue automation after the local commit")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Edit details" }).click();
|
||||
await expect(page.getByLabel("End odometer (km)")).toHaveValue("60000");
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="MobilityOps synthetic-data operations proof of concept" />
|
||||
<title>MobilityOps</title>
|
||||
<meta name="description" content="Fleet Ops synthetic-data operations demo" />
|
||||
<title>Fleet Ops</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
"name": "mobilityops-web",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"i18next": "^26.3.6",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-router-dom": "7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -255,6 +257,15 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
@@ -1463,6 +1474,43 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://locize.com"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -1661,6 +1709,33 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.11",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
|
||||
"integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^4.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||
@@ -1794,7 +1869,7 @@
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
||||
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -1835,6 +1910,15 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "^26.3.6",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-router-dom": "7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import { DemoManifestProvider } from "./context/DemoManifestContext";
|
||||
import { DemoGuideProvider } from "./context/DemoGuideContext";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { RequireAuth } from "./components/RequireAuth";
|
||||
import { Login } from "./pages/Login";
|
||||
@@ -13,10 +15,14 @@ import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail";
|
||||
import { Automation } from "./pages/Automation";
|
||||
import { Knowledge } from "./pages/Knowledge";
|
||||
import { Audit } from "./pages/Audit";
|
||||
import { AboutDemo } from "./pages/AboutDemo";
|
||||
import { Scenarios } from "./pages/Scenarios";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<DemoManifestProvider>
|
||||
<AuthProvider>
|
||||
<DemoGuideProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
@@ -36,10 +42,14 @@ export function App() {
|
||||
<Route path="/automation" element={<Automation />} />
|
||||
<Route path="/knowledge" element={<Knowledge />} />
|
||||
<Route path="/audit" element={<Audit />} />
|
||||
<Route path="/about" element={<AboutDemo />} />
|
||||
<Route path="/scenarios" element={<Scenarios />} />
|
||||
</Route>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</DemoGuideProvider>
|
||||
</AuthProvider>
|
||||
</DemoManifestProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
|
||||
type UnauthorizedListener = () => void;
|
||||
const unauthorizedListeners = new Set<UnauthorizedListener>();
|
||||
|
||||
export function onUnauthorized(listener: UnauthorizedListener): () => void {
|
||||
unauthorizedListeners.add(listener);
|
||||
return () => unauthorizedListeners.delete(listener);
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
@@ -31,6 +39,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
body = undefined;
|
||||
}
|
||||
const error = body?.error;
|
||||
if (response.status === 401) {
|
||||
unauthorizedListeners.forEach((listener) => listener());
|
||||
}
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
error?.code ?? String(response.status),
|
||||
|
||||
@@ -88,7 +88,7 @@ export interface DashboardMetrics {
|
||||
export interface AttentionItem {
|
||||
kind: string;
|
||||
severity: "low" | "medium" | "high";
|
||||
title: string;
|
||||
rule_type: string;
|
||||
detail: string;
|
||||
link_type: "vehicle" | "booking" | "customer";
|
||||
link_ref: string;
|
||||
@@ -145,7 +145,22 @@ export interface RegisterReturnResult {
|
||||
next_booking_risk: NextBookingRisk | null;
|
||||
}
|
||||
|
||||
export interface ReturnPreviewResult {
|
||||
booking_ref: string;
|
||||
vehicle_ref: string;
|
||||
canonical_odometer_km: number;
|
||||
submitted_odometer_km: number;
|
||||
odometer_regression: boolean;
|
||||
resulting_odometer_km: number;
|
||||
resulting_vehicle_status: string;
|
||||
status_reason: string;
|
||||
would_create_quality_issue: boolean;
|
||||
attention_reasons: string[];
|
||||
next_booking_risk: NextBookingRisk | null;
|
||||
}
|
||||
|
||||
export interface EntitySnapshot {
|
||||
entity_type: "customer" | "vehicle" | "booking" | "inspection";
|
||||
public_ref: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -155,6 +170,28 @@ export interface DataQualityIssueDetail extends DataQualityIssue {
|
||||
related_snapshots: EntitySnapshot[];
|
||||
}
|
||||
|
||||
export interface ApplyRecommendedStatusResult {
|
||||
issue: DataQualityIssue;
|
||||
applied_status: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
created: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface SearchResultItem {
|
||||
type: "vehicle" | "booking" | "data_quality_issue" | "section";
|
||||
label: string;
|
||||
detail: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
query: string;
|
||||
results: SearchResultItem[];
|
||||
}
|
||||
|
||||
export interface MergeCustomersRequest {
|
||||
survivor_ref: string;
|
||||
field_overrides?: Record<string, string>;
|
||||
@@ -193,6 +230,59 @@ export interface KnowledgeHealth {
|
||||
document_count: number;
|
||||
}
|
||||
|
||||
export interface N8nIntegrationStatus {
|
||||
configured: boolean;
|
||||
dispatch_enabled: boolean;
|
||||
state: "disabled" | "unavailable" | "degraded" | "operational" | "no_evidence";
|
||||
pending: number;
|
||||
delivering: number;
|
||||
failed: number;
|
||||
succeeded: number;
|
||||
latest_success_at: string | null;
|
||||
latest_failure_at: string | null;
|
||||
}
|
||||
|
||||
export interface McpHubIntegrationStatus {
|
||||
registration_enabled: boolean;
|
||||
state: "not_configured" | "configured";
|
||||
}
|
||||
|
||||
export interface IntegrationStatus {
|
||||
n8n: N8nIntegrationStatus;
|
||||
mcp_hub: McpHubIntegrationStatus;
|
||||
}
|
||||
|
||||
export interface DemoScenario {
|
||||
id: string;
|
||||
estimated_minutes: number;
|
||||
required_roles: Role[];
|
||||
start_path: string;
|
||||
ready: boolean;
|
||||
blocked_reason_code: string | null;
|
||||
blocked_reason_params: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface DemoIntegrationSummary {
|
||||
key: "n8n" | "ragcore" | "mcp_hub";
|
||||
status_code: string;
|
||||
detail_code: string;
|
||||
detail_params: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface DemoManifest {
|
||||
demo_mode: boolean;
|
||||
organization_name: string;
|
||||
timezone: string;
|
||||
synthetic_data: boolean;
|
||||
allow_reset: boolean;
|
||||
last_reset_at: string | null;
|
||||
anchor_date: string | null;
|
||||
guide_available: boolean;
|
||||
required_roles: Role[];
|
||||
scenarios: DemoScenario[];
|
||||
integrations: DemoIntegrationSummary[];
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
actor_type: string;
|
||||
@@ -200,7 +290,11 @@ export interface AuditEvent {
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: string | null;
|
||||
entity_ref: string | null;
|
||||
entity_link: string | null;
|
||||
correlation_id: string;
|
||||
occurred_at: string;
|
||||
before: Record<string, unknown> | null;
|
||||
after: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
|
||||
const label = severity === "high" ? "High" : severity === "medium" ? "Medium" : "Low";
|
||||
return <span className={`badge severity-${severity}`}>{label} severity</span>;
|
||||
const { t } = useTranslation("quality");
|
||||
return <span className={`badge severity-${severity}`}>{t(`severities.${severity}`)}</span>;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
return <span className={`badge status-${status}`}>{status.replace(/_/g, " ")}</span>;
|
||||
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||
return <span className={`badge status-${status}`}>{label ?? status.replace(/_/g, " ")}</span>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { Icon } from "./Icons";
|
||||
|
||||
export function DemoBadge() {
|
||||
const { t } = useTranslation("demo");
|
||||
const { formatDateTime } = useLocaleFormat();
|
||||
const { manifest } = useDemoManifest();
|
||||
const [open, setOpen] = useState(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
if (boxRef.current && !boxRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="demo-badge" ref={boxRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="demo-badge-trigger"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<Icon name="shield" />
|
||||
<span>{t("badge.trigger")}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="demo-badge-popover" role="dialog" aria-label={t("badge.dialogLabel")}>
|
||||
<button type="button" className="icon-button demo-badge-close" onClick={() => setOpen(false)} aria-label={t("badge.close")}>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
<p>
|
||||
{manifest ? (
|
||||
<Trans i18nKey="badge.orgIntro" t={t} values={{ orgName: manifest.organization_name }} components={{ strong: <strong /> }} />
|
||||
) : (
|
||||
t("badge.orgIntroFallback")
|
||||
)}
|
||||
</p>
|
||||
<p>{t("badge.realWorkflows")}</p>
|
||||
{manifest && (
|
||||
<p className="demo-badge-reset">
|
||||
<Trans
|
||||
i18nKey="badge.lastReset"
|
||||
t={t}
|
||||
values={{ when: manifest.last_reset_at ? formatDateTime(manifest.last_reset_at) : t("badge.unknown") }}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<Link to="/about" onClick={() => setOpen(false)}>
|
||||
{t("badge.aboutLink")} <Icon name="chevron" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useViewportTier } from "../hooks/useViewportTier";
|
||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||
import { Icon } from "./Icons";
|
||||
|
||||
export function DemoGuideTrigger() {
|
||||
const { t } = useTranslation("demo");
|
||||
const { user } = useAuth();
|
||||
const { open, toggleGuide, currentIndex, completed, totalSteps } = useDemoGuide();
|
||||
|
||||
if (user?.role !== "operations_manager") return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="demo-guide-trigger"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
onClick={toggleGuide}
|
||||
>
|
||||
<Icon name="spark" />
|
||||
<span>{t("guide.trigger")}</span>
|
||||
<span className="demo-guide-progress-pill">{completed.size}/{totalSteps}</span>
|
||||
<span className="visually-hidden">, {t("guide.kicker", { current: currentIndex + 1, total: totalSteps })}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function highlightTarget(selector: string | undefined) {
|
||||
if (!selector) return;
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const previousTabIndex = el.getAttribute("tabindex");
|
||||
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
|
||||
el.focus({ preventScroll: true });
|
||||
el.classList.add("demo-guide-highlight");
|
||||
window.setTimeout(() => {
|
||||
el.classList.remove("demo-guide-highlight");
|
||||
if (previousTabIndex === null) el.removeAttribute("tabindex");
|
||||
}, 2200);
|
||||
}
|
||||
|
||||
export function DemoGuide() {
|
||||
const { t } = useTranslation("demo");
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { logout } = useAuth();
|
||||
const { manifest, refresh } = useDemoManifest();
|
||||
const tier = useViewportTier();
|
||||
const {
|
||||
open,
|
||||
closeGuide,
|
||||
currentIndex,
|
||||
completed,
|
||||
totalSteps,
|
||||
goToStep,
|
||||
completeAndAdvance,
|
||||
restart,
|
||||
collapsedToChip,
|
||||
setCollapsedToChip,
|
||||
} = useDemoGuide();
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [resetError, setResetError] = useState<string | null>(null);
|
||||
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
|
||||
const pendingTarget = useRef<string | null>(null);
|
||||
|
||||
const step = DEMO_GUIDE_STEPS[currentIndex];
|
||||
const isLastStep = currentIndex === totalSteps - 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape") return;
|
||||
if (tier === "standard" && !collapsedToChip) {
|
||||
setCollapsedToChip(true);
|
||||
} else if (tier === "mobile" && mobileSheetState !== "collapsed") {
|
||||
setMobileSheetState("collapsed");
|
||||
} else {
|
||||
closeGuide();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
return () => document.removeEventListener("keydown", handleKeydown);
|
||||
}, [open, tier, collapsedToChip, mobileSheetState, closeGuide]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingTarget.current) return;
|
||||
const target = pendingTarget.current;
|
||||
pendingTarget.current = null;
|
||||
const raf = requestAnimationFrame(() => highlightTarget(target));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [location.pathname]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function goToStepRoute() {
|
||||
pendingTarget.current = step.target ?? null;
|
||||
navigate(step.route(manifest));
|
||||
if (tier === "standard") setCollapsedToChip(true);
|
||||
if (tier === "mobile") setMobileSheetState("collapsed");
|
||||
}
|
||||
|
||||
async function handleRestartDemo() {
|
||||
setResetError(null);
|
||||
setResetting(true);
|
||||
try {
|
||||
await api.post("/api/v1/demo/reset");
|
||||
restart();
|
||||
refresh();
|
||||
closeGuide();
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed"));
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (tier === "standard" && collapsedToChip) {
|
||||
return (
|
||||
<div className="demo-guide-chip">
|
||||
<button
|
||||
type="button"
|
||||
className="demo-guide-chip-expand"
|
||||
onClick={() => setCollapsedToChip(false)}
|
||||
aria-label={`${t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}, ${t("guide.expand")}`}
|
||||
>
|
||||
<Icon name="spark" />
|
||||
{t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}
|
||||
<Icon name="chevron" />
|
||||
</button>
|
||||
<button type="button" className="demo-guide-chip-close" onClick={closeGuide} aria-label={t("guide.close")}>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const panelClassName = [
|
||||
"demo-guide-panel",
|
||||
tier === "wide" ? "is-wide" : "",
|
||||
tier === "mobile" ? `is-mobile sheet-${mobileSheetState}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<aside className={panelClassName} role="dialog" aria-label={t("guide.dialogLabel")}>
|
||||
{tier === "mobile" && (
|
||||
<button
|
||||
type="button"
|
||||
className="demo-guide-sheet-handle"
|
||||
onClick={() =>
|
||||
setMobileSheetState((s) => (s === "collapsed" ? "half" : s === "half" ? "full" : "collapsed"))
|
||||
}
|
||||
aria-label={
|
||||
mobileSheetState === "full"
|
||||
? t("guide.collapse")
|
||||
: t("guide.expand")
|
||||
}
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<header className="demo-guide-header">
|
||||
<div>
|
||||
<p className="demo-guide-kicker">{t("guide.kicker", { current: currentIndex + 1, total: totalSteps })}</p>
|
||||
<h2>{t(`guide.steps.${step.id}.title`)}</h2>
|
||||
</div>
|
||||
{tier !== "mobile" && (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={tier === "standard" ? () => setCollapsedToChip(true) : closeGuide}
|
||||
aria-label={tier === "standard" ? t("guide.collapse") : t("guide.close")}
|
||||
>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{mobileSheetState !== "collapsed" && (
|
||||
<>
|
||||
<div className="demo-guide-progress-bar" aria-hidden="true">
|
||||
{DEMO_GUIDE_STEPS.map((s, index) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className={
|
||||
index === currentIndex ? "is-current" : completed.has(s.id) ? "is-done" : ""
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(mobileSheetState !== "half" || tier !== "mobile") && (
|
||||
<div className="demo-guide-body">
|
||||
<p><strong>{t("guide.whatYouWillSee")}</strong><br />{t(`guide.steps.${step.id}.whatYouWillSee`)}</p>
|
||||
<p><strong>{t("guide.whyItMatters")}</strong><br />{t(`guide.steps.${step.id}.whyItMatters`)}</p>
|
||||
<p><strong>{t("guide.startAction")}</strong><br />{t(`guide.steps.${step.id}.startAction`)}</p>
|
||||
<p><strong>{t("guide.expectedOutcome")}</strong><br />{t(`guide.steps.${step.id}.expectedOutcome`)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tier !== "mobile" && (
|
||||
<nav className="demo-guide-steps" aria-label={t("guide.allStepsLabel")}>
|
||||
{DEMO_GUIDE_STEPS.map((s, index) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={index === currentIndex ? "is-current" : ""}
|
||||
onClick={() => goToStep(index)}
|
||||
>
|
||||
{completed.has(s.id) && <Icon name="check" />}
|
||||
{t(`guide.steps.${s.id}.title`)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
||||
|
||||
<footer className="demo-guide-footer">
|
||||
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
|
||||
{t("guide.goToStep")}
|
||||
</button>
|
||||
<button type="button" className="button button-primary" onClick={completeAndAdvance} disabled={isLastStep}>
|
||||
{t("guide.next")}
|
||||
</button>
|
||||
{tier !== "mobile" && (
|
||||
<button type="button" className="demo-guide-restart" onClick={handleRestartDemo} disabled={resetting}>
|
||||
{resetting ? t("guide.restarting") : t("guide.restart")}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
persistLanguage,
|
||||
SUPPORTED_LANGUAGES,
|
||||
type SupportedLanguage,
|
||||
} from "../i18n/config";
|
||||
|
||||
export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const current = (i18n.language as SupportedLanguage) || "nl-BE";
|
||||
|
||||
function handleChange(next: SupportedLanguage) {
|
||||
void i18n.changeLanguage(next);
|
||||
persistLanguage(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<label className={`language-switcher ${compact ? "language-switcher-compact" : ""}`}>
|
||||
<span className="visually-hidden">{t("language.label")}</span>
|
||||
<select
|
||||
value={current}
|
||||
onChange={(event) => handleChange(event.target.value as SupportedLanguage)}
|
||||
aria-label={t("language.label")}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{t(`language.${lang}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +1,78 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import type { Role, SearchResultItem } from "../api/types";
|
||||
import { BrandMark, Icon, type IconName } from "./Icons";
|
||||
import { DemoBadge } from "./DemoBadge";
|
||||
import { DemoGuide, DemoGuideTrigger } from "./DemoGuide";
|
||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
|
||||
const NAV_GROUPS: Array<{ label: string; items: Array<{ to: string; label: string; shortLabel: string; icon: IconName }> }> = [
|
||||
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
||||
vehicle: "fleet",
|
||||
booking: "bookings",
|
||||
data_quality_issue: "quality",
|
||||
section: "chevron",
|
||||
};
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
labelKey: string;
|
||||
icon: IconName;
|
||||
roles?: Role[];
|
||||
}
|
||||
|
||||
const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
|
||||
{
|
||||
label: "Operate",
|
||||
labelKey: "groups.operate",
|
||||
items: [
|
||||
{ to: "/dashboard", label: "Overview", shortLabel: "Overview", icon: "activity" },
|
||||
{ to: "/vehicles", label: "Fleet", shortLabel: "Fleet", icon: "fleet" },
|
||||
{ to: "/bookings", label: "Bookings", shortLabel: "Bookings", icon: "bookings" },
|
||||
{ to: "/data-quality", label: "Data quality", shortLabel: "Quality", icon: "quality" },
|
||||
{ to: "/dashboard", labelKey: "items.overview", icon: "activity" },
|
||||
{ to: "/vehicles", labelKey: "items.fleet", icon: "fleet" },
|
||||
{ to: "/bookings", labelKey: "items.bookings", icon: "bookings" },
|
||||
{ to: "/data-quality", labelKey: "items.quality", icon: "quality", roles: ["operations_manager"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Assure",
|
||||
labelKey: "groups.assure",
|
||||
items: [
|
||||
{ to: "/knowledge", label: "Knowledge", shortLabel: "Knowledge", icon: "knowledge" },
|
||||
{ to: "/automation", label: "Integrations", shortLabel: "Systems", icon: "integrations" },
|
||||
{ to: "/audit", label: "Audit trail", shortLabel: "Audit", icon: "audit" },
|
||||
{ to: "/knowledge", labelKey: "items.knowledge", icon: "knowledge" },
|
||||
{ to: "/automation", labelKey: "items.integrations", icon: "integrations", roles: ["operations_manager"] },
|
||||
{ to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const MOBILE_ITEMS = NAV_GROUPS.flatMap((group) => group.items).slice(0, 5);
|
||||
|
||||
const SEARCH_DESTINATIONS = [
|
||||
{ to: "/dashboard", terms: ["overview", "dashboard", "readiness"] },
|
||||
{ to: "/vehicles", terms: ["fleet", "vehicle", "vehicles"] },
|
||||
{ to: "/bookings", terms: ["booking", "bookings", "rental"] },
|
||||
{ to: "/data-quality", terms: ["quality", "data quality", "issues"] },
|
||||
{ to: "/knowledge", terms: ["knowledge", "procedures"] },
|
||||
{ to: "/automation", terms: ["automation", "integrations", "systems", "n8n"] },
|
||||
{ to: "/audit", terms: ["audit", "history"] },
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
const { t } = useTranslation(["navigation", "common", "auth"]);
|
||||
const { user, logout } = useAuth();
|
||||
const { manifest } = useDemoManifest();
|
||||
const { open: guideOpen, collapsedToChip: guideCollapsed } = useDemoGuide();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchStatus, setSearchStatus] = useState("");
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [searchError, setSearchError] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [resetConfirming, setResetConfirming] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [resetError, setResetError] = useState<string | null>(null);
|
||||
const searchInput = useRef<HTMLInputElement>(null);
|
||||
const searchBox = useRef<HTMLDivElement>(null);
|
||||
|
||||
const navGroups = useMemo(
|
||||
() =>
|
||||
NAV_GROUPS.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => !item.roles || (user && item.roles.includes(user.role))),
|
||||
})).filter((group) => group.items.length > 0),
|
||||
[user],
|
||||
);
|
||||
const mobileItems = useMemo(() => navGroups.flatMap((group) => group.items).slice(0, 5), [navGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
function focusGlobalSearch(event: KeyboardEvent) {
|
||||
@@ -55,59 +86,112 @@ export function Layout() {
|
||||
return () => window.removeEventListener("keydown", focusGlobalSearch);
|
||||
}, []);
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
useEffect(() => {
|
||||
const query = searchQuery.trim();
|
||||
if (!query) {
|
||||
setSearchResults([]);
|
||||
setSearchLoading(false);
|
||||
setSearchError(false);
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
setSearchLoading(true);
|
||||
setSearchError(false);
|
||||
const timeout = window.setTimeout(() => {
|
||||
api
|
||||
.get<{ query: string; results: SearchResultItem[] }>(
|
||||
`/api/v1/search?q=${encodeURIComponent(query)}`,
|
||||
)
|
||||
.then((response) => {
|
||||
setSearchResults(response.results);
|
||||
setActiveIndex(-1);
|
||||
})
|
||||
.catch(() => setSearchError(true))
|
||||
.finally(() => setSearchLoading(false));
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
if (searchBox.current && !searchBox.current.contains(event.target as Node)) {
|
||||
setSearchOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
return () => document.removeEventListener("mousedown", handleOutsideClick);
|
||||
}, []);
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
}
|
||||
|
||||
function handleSearch(event: FormEvent<HTMLFormElement>) {
|
||||
async function handleDemoReset() {
|
||||
setResetError(null);
|
||||
setResetting(true);
|
||||
try {
|
||||
await api.post("/api/v1/demo/reset");
|
||||
// The server invalidates the acting session as part of reset; drop local state the
|
||||
// same way an explicit logout would and return to the login screen.
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
setResetError(err instanceof ApiError ? err.message : t("resetFailed"));
|
||||
setResetConfirming(false);
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function selectResult(item: SearchResultItem) {
|
||||
setSearchOpen(false);
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
navigate(item.link);
|
||||
}
|
||||
|
||||
function handleSearchKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "Escape") {
|
||||
setSearchOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!searchOpen || searchResults.length === 0) return;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
const query = searchQuery.trim();
|
||||
if (!query) {
|
||||
setSearchStatus("Enter a section or a vehicle, booking or issue reference.");
|
||||
return;
|
||||
setActiveIndex((i) => (i + 1) % searchResults.length);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((i) => (i <= 0 ? searchResults.length - 1 : i - 1));
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const target = searchResults[activeIndex] ?? searchResults[0];
|
||||
if (target) selectResult(target);
|
||||
}
|
||||
const publicRef = query.toUpperCase();
|
||||
let destination: string | undefined;
|
||||
|
||||
if (/^MO-\d+$/.test(publicRef)) destination = `/vehicles/${publicRef}`;
|
||||
else if (/^BK-[A-Z0-9-]+$/.test(publicRef)) destination = `/bookings/${publicRef}`;
|
||||
else if (/^DQ-[A-Z0-9-]+$/.test(publicRef)) destination = `/data-quality/${publicRef}`;
|
||||
else {
|
||||
const normalized = query.toLowerCase();
|
||||
destination = SEARCH_DESTINATIONS.find(({ terms }) =>
|
||||
terms.some((term) => term.includes(normalized) || normalized.includes(term)),
|
||||
)?.to;
|
||||
}
|
||||
|
||||
if (destination) {
|
||||
setSearchStatus("");
|
||||
navigate(destination);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchStatus(`No destination found for ${query}. Try a vehicle, booking or issue reference.`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<a className="skip-link" href="#main-content">Skip to main content</a>
|
||||
<a className="skip-link" href="#main-content">{t("skipToContent")}</a>
|
||||
|
||||
<aside className={`sidebar ${mobileOpen ? "is-open" : ""}`}>
|
||||
<div className="brand-lockup">
|
||||
<BrandMark className="brand-mark" />
|
||||
<div><strong>MobilityOps</strong><span>Control centre</span></div>
|
||||
<div><strong>{t("common:appName")}</strong><span>{t("common:brandTagline")}</span></div>
|
||||
</div>
|
||||
<nav aria-label="Primary navigation">
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div className="nav-group" key={group.label}>
|
||||
<p>{group.label}</p>
|
||||
<div className="sidebar-language">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<nav aria-label={t("primaryNavLabel")}>
|
||||
{navGroups.map((group) => (
|
||||
<div className="nav-group" key={group.labelKey}>
|
||||
<p>{t(group.labelKey)}</p>
|
||||
<ul>
|
||||
{group.items.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink to={item.to} onClick={() => setMobileOpen(false)}>
|
||||
<Icon name={item.icon} />
|
||||
<span>{item.label}</span>
|
||||
<span>{t(item.labelKey)}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
@@ -117,66 +201,124 @@ export function Layout() {
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<span className="environment-dot" />
|
||||
<div><strong>Demo environment</strong><span>Synthetic data only</span></div>
|
||||
<div><strong>{t("sidebarEnvironment")}</strong><span>{t("sidebarEnvironmentDetail")}</span></div>
|
||||
</div>
|
||||
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
|
||||
<div className="sidebar-reset">
|
||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
||||
{!resetConfirming ? (
|
||||
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
|
||||
{t("resetDemoData")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="confirm-bar" role="alertdialog" aria-label={t("resetConfirmTitle")}>
|
||||
<p>{t("resetConfirmBody")}</p>
|
||||
<button type="button" onClick={handleDemoReset} disabled={resetting}>
|
||||
{resetting ? t("resetting") : t("resetConfirmYes")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setResetConfirming(false)} disabled={resetting}>
|
||||
{t("resetCancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{mobileOpen && <button className="nav-scrim" aria-label="Close navigation" onClick={() => setMobileOpen(false)} />}
|
||||
{mobileOpen && <button className="nav-scrim" aria-label={t("closeNavigation")} onClick={() => setMobileOpen(false)} />}
|
||||
|
||||
<div className="app-workspace">
|
||||
<div className={`app-workspace ${guideOpen ? "guide-open" : ""} ${guideOpen && guideCollapsed ? "guide-collapsed" : ""}`}>
|
||||
<header className="topbar">
|
||||
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label="Open navigation">
|
||||
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label={t("openNavigation")}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<form className="global-search" role="search" onSubmit={handleSearch}>
|
||||
<div className="global-search" role="search" ref={searchBox}>
|
||||
<Icon name="search" />
|
||||
<label className="visually-hidden" htmlFor="global-search-input">Search MobilityOps</label>
|
||||
<label className="visually-hidden" htmlFor="global-search-input">{t("searchLabel")}</label>
|
||||
<input
|
||||
id="global-search-input"
|
||||
ref={searchInput}
|
||||
type="search"
|
||||
role="combobox"
|
||||
aria-expanded={searchOpen}
|
||||
aria-controls="global-search-results"
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeIndex >= 0 ? `search-result-${activeIndex}` : undefined}
|
||||
value={searchQuery}
|
||||
placeholder="Search fleet, booking or section…"
|
||||
aria-describedby="global-search-status"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
onChange={(event) => {
|
||||
setSearchQuery(event.target.value);
|
||||
setSearchStatus("");
|
||||
setSearchOpen(true);
|
||||
}}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
<kbd>Ctrl K</kbd>
|
||||
<span id="global-search-status" className="visually-hidden" aria-live="polite">{searchStatus}</span>
|
||||
</form>
|
||||
<kbd>{t("searchShortcutHint")}</kbd>
|
||||
{searchOpen && searchQuery.trim() && (
|
||||
<div className="search-results" id="global-search-results" role="listbox">
|
||||
{searchLoading && <p className="search-status">{t("searchSearching")}</p>}
|
||||
{!searchLoading && searchError && <p className="search-status">{t("searchUnavailable")}</p>}
|
||||
{!searchLoading && !searchError && searchResults.length === 0 && (
|
||||
<p className="search-status">{t("searchNoResults", { query: searchQuery.trim() })}</p>
|
||||
)}
|
||||
{!searchLoading &&
|
||||
!searchError &&
|
||||
searchResults.map((item, index) => (
|
||||
<button
|
||||
key={`${item.type}-${item.link}`}
|
||||
id={`search-result-${index}`}
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
type="button"
|
||||
className={`search-result ${index === activeIndex ? "is-active" : ""}`}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => selectResult(item)}
|
||||
>
|
||||
<Icon name={SEARCH_ICON[item.type]} />
|
||||
<span className="search-result-copy">
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.detail}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-meta">
|
||||
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
|
||||
<LanguageSwitcher compact />
|
||||
<DemoGuideTrigger />
|
||||
<DemoBadge />
|
||||
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
|
||||
{user && (
|
||||
<div className="operator">
|
||||
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span>
|
||||
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? "Operations manager" : "Rental employee"}</small></span>
|
||||
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
|
||||
</div>
|
||||
)}
|
||||
<button className="icon-button" type="button" onClick={handleLogout} aria-label="Switch role" title="Switch demo role">
|
||||
<button className="icon-button" type="button" onClick={handleLogout} aria-label={t("switchRole")} title={t("switchRoleTitle")}>
|
||||
<Icon name="logout" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p className="demo-banner"><Icon name="shield" /> Synthetic demo data · no real customer or vehicle information</p>
|
||||
<main id="main-content" tabIndex={-1}><Outlet /></main>
|
||||
<footer className="app-footer"><span>MobilityOps PoC</span><span>Europe/Brussels · Synthetic demo data</span></footer>
|
||||
<footer className="app-footer"><span>{t("common:footer.productLine")}</span><span>{t("common:footer.locale")}</span></footer>
|
||||
</div>
|
||||
|
||||
<nav className="mobile-nav" aria-label="Mobile navigation">
|
||||
{MOBILE_ITEMS.map((item) => (
|
||||
<nav className="mobile-nav" aria-label={t("mobileNavLabel")}>
|
||||
{mobileItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to}>
|
||||
<Icon name={item.icon} />
|
||||
<span>{item.shortLabel}</span>
|
||||
<span>{t(item.labelKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<button type="button" onClick={() => setMobileOpen(true)}>
|
||||
<Icon name="menu" />
|
||||
<span>More</span>
|
||||
<span>{t("more")}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<DemoGuide />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Icon, type IconName } from "./Icons";
|
||||
|
||||
export function PageHeader({
|
||||
@@ -28,15 +29,17 @@ export function SectionHeading({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
headingId,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
headingId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<h2 id={headingId}>{title}</h2>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
@@ -44,20 +47,22 @@ export function SectionHeading({
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({ label = "Loading workspace…" }: { label?: string }) {
|
||||
export function LoadingState({ label }: { label?: string }) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div className="state-panel" role="status">
|
||||
<span className="spinner" aria-hidden="true" />
|
||||
<p>{label}</p>
|
||||
<p>{label ?? t("states.loadingDefault")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({ message }: { message: string }) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<Icon name="alert" />
|
||||
<div><strong>We couldn’t load this workspace.</strong><p>{message}</p></div>
|
||||
<div><strong>{t("states.errorTitle")}</strong><p>{message}</p></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { LoadingState } from "./PageChrome";
|
||||
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page">
|
||||
<LoadingState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||