Author SHA1 Message Date
NuklearRabbitandClaude Sonnet 5 845db14e17 fix: mobile topbar overflow at 421-440px and add trilingual responsive coverage
The 420px "compact topbar" breakpoint left a gap: at 421-440px the demo-guide trigger,
badge, operator block and logout button together overflowed the viewport (introduced by
this session's language-switcher addition). Widen the breakpoint to 440px.

Adds a dedicated Playwright spec asserting no horizontal overflow across the brief's full
7-breakpoint matrix (1440x1000 down to 360x800) in all three supported languages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 18:41:14 +02:00
NuklearRabbitandClaude Sonnet 5 337f8716bb polish: rebrand to Fleet Ops, add trilingual i18n, adaptive demo guide, and UX overhaul
Rebrands the product from MobilityOps to Fleet Ops across the UI, backend defaults and
knowledge base, and makes nl-BE/en-GB/fr-BE full first-class languages: i18next with
eager-bundled per-namespace resources, a persisted accessible language switcher (topbar
and mobile drawer), locale-aware date/number formatting, and a coverage test that fails
the build on any missing or empty translation key.

Backend dynamic content (demo scenarios, blocked-reason text, integration status) moves
from fixed English/Dutch prose to stable message codes + params so the frontend can
localize it; the demo knowledge base gains a fully translated NL/EN/FR procedure corpus
(11 documents each) with per-language retrieval and localized evidence-state messages.

The Demo Guide becomes breakpoint-adaptive: a docked rail on extra-wide desktop, a
floating panel that auto-collapses to a persistent, closable progress chip on standard
desktop/tablet, and a collapsed/half/full bottom sheet on mobile -- with scroll+focus+
highlight on "go to this step", Escape handling, and reduced-motion support.

The Data Quality Workbench gets accessible choice-card decisions with a clear primary/
secondary/tertiary action hierarchy; the Automation ledger groups repeated successes and
uses meaningful short refs; the Audit trail groups events by correlation id with human
action labels and readable before/after diffs. Attention Queue, Today's movements,
Vehicles, Bookings and Data Quality rows are fully clickable (stretched-link pattern)
with independent secondary links, keyboard support and mobile touch targets.

Fixes a topbar overflow on mobile caused by the new language switcher (moved into the
mobile drawer at <=960px) and two dangling aria-labelledby references introduced this
session. Updates all affected Playwright specs for the new nl-BE default and the new
Audit/DemoGuide DOM structure, and adds new i18n-coverage, demo-guide-adaptive and
clickable-rows specs. 131 backend tests, Ruff and mypy, and 71 Playwright tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 18:33:22 +02:00
NuklearRabbit 257a4cf6c0 docs(polish): audit finale demo-afwerking
Verifies the actual branch HEAD against the deployed Unraid revision
(they match) and corrects a one-commit-behind final-commit hash in
artifacts/demo-release/final-summary.md (its own "record the hash"
follow-up commit couldn't self-reference). Catalogues remaining
MobilityOps/PoC mentions (including in knowledge-base procedure prose
that gets quoted in answers), confirms no i18n exists, documents the
Demo Guide's single-behaviour-at-all-desktop-widths gap, the
inconsistent radio-vs-card decision styling in Data Quality, automation/
audit density, and exactly which dashboard rows aren't fully clickable.
Notes the repository's primary branch is `master`, not `main`.
2026-08-03 15:58:48 +02:00
NuklearRabbit 4a268c7351 docs(release): record the final commit hash in the demo-release summary 2026-08-03 15:31:39 +02:00
NuklearRabbit 294a8176d1 docs(release): finalize demo-productization acceptance evidence
Records the clean-checkout drill result (127 backend tests, 56
Playwright tests, all green on an isolated fresh clone), final live
Unraid verification, and the complete required evidence summary for
the demo-productization work on this branch.
2026-08-03 15:31:13 +02:00
NuklearRabbit a5024f7190 docs(demo): add demo-release evidence screenshots and capture tooling
One-off Playwright script (excluded from the regular suite) capturing
the demo entry (desktop+mobile), dashboard with scenarios, Demo Guide,
return preview/result, data-quality resolution, duplicate-customer
merge, knowledge assistant, integration status, automation retry,
audit trail + related-events, About page, demo badge popover, and
reset confirmation -- captured live against the Unraid deployment.
2026-08-03 15:29:53 +02:00
NuklearRabbit 38f654b97a docs(demo): add demo concept, scenarios, data, guide and runbook docs
Documents the demo-productization work from this branch: the Northstar
Mobility fictional concept and scope, the five named scenarios and their
fixed records, the seed/date-anchoring strategy (including the real bug
it fixed), the in-app Demo Guide's design and the English-suggested-
questions decision, and an operational runbook covering 5/10-minute demo
flows, reset, Unraid redeploy and rollback. Updates README with current
test counts and pointers to the new docs.
2026-08-03 15:24:35 +02:00
NuklearRabbit f04a81f6c7 docs: record Unraid deployment evidence for guided-demo test batch 2026-08-03 15:14:20 +02:00
NuklearRabbit 07d5605812 test(demo): add full guided-demo walkthrough and targeted demo tests
Adds one comprehensive Playwright test that walks a fresh Operations
Manager session through all 8 Demo Guide steps performing the real
action at each one, then restores the environment. Writing it surfaced
a real desktop layout bug: the Demo Guide's fixed side panel overlapped
main content with no reflow, making the return form's "Review return"
button unclickable while the guide was open at ordinary viewport widths.
Fixed by reserving layout space via a guide-open class. Also adds mobile
bottom-sheet, keyboard-reachability, and console-error checks.
2026-08-03 15:12:29 +02:00
NuklearRabbit 65835ea40a docs: record Unraid deployment evidence for integration/audit/about batch 2026-08-03 15:02:37 +02:00
NuklearRabbit 5fa4fe0811 feat(demo): plain-language integration status, richer audit, reset integrity
Integration status badges across Dashboard/Automation now show honest
plain-language labels instead of raw backend state strings (and fix a
few states that had no matching CSS colour class at all). Audit trail
gets a "view related events" action reusing the existing correlation_id
filter. About page gains scope/architecture/security/testing sections
and a guided-demo entry point. POST /api/v1/demo/reset now runs and
records a server-side scenario-integrity check. Also fixes a second real
race condition (caught by the return-review e2e test): the odometer
scenario pre-fill now resolves before ReturnForm mounts instead of
patching its value in after the fact.
2026-08-03 15:00:11 +02:00
NuklearRabbit cf9a889547 docs: record Unraid deployment evidence for demo-legibility batch 2026-08-03 14:42:49 +02:00
NuklearRabbit ddc3a98e4b feat(demo): make return, data-quality and knowledge flows demo-legible
Fixes a real honesty bug in Knowledge.tsx (body copy named "RAGcore" while
the active provider is the demo one) and a second real bug discovered
while fixing it: the brief's suggested Dutch questions would silently
return "insufficient evidence" against the English-only demo knowledge
base -- verified empirically and fixed by keeping suggested questions in
English. The return flow now pre-fills the odometer-regression scenario's
suspicious reading instead of asking a visitor to invent one, and links
to automation/audit after committing. Data-quality issues get a shared
plain-language "what's wrong / why it matters" explainer per rule type,
a post-resolution confirmation with audit/vehicle links, and a "demo
scenario's only" list filter. Also fixes a real async race where the
odometer pre-fill could clobber text a visitor had already started typing.
2026-08-03 14:41:02 +02:00
NuklearRabbit 6b864596e0 docs: record Unraid deployment evidence for Demo Guide/scenario overview batch 2026-08-03 14:18:47 +02:00
NuklearRabbit 14c2ad3ee8 fix(demo): wait for post-login redirect before navigating in e2e tests
Two demo-guide.spec.ts tests navigated straight to /scenarios right after
clicking a login button without waiting for the /dashboard redirect to
settle first. This raced harmlessly on localhost but flaked against the
higher-latency Unraid deployment, hitting RequireAuth before the session
was confirmed.
2026-08-03 14:17:44 +02:00
NuklearRabbit 9fff84dc68 feat(demo): add Demo Guide (8-step guided tour) and scenario overview
Adds a compact "Probeer een demonstratiescenario" page listing the 5 named
scenarios with live readiness from the manifest, plus a Demo Guide side
panel (bottom sheet on mobile) that walks an Operations Manager through
all 8 steps with per-step context, live-resolved routes, sessionStorage
progress, and a "Demo opnieuw voorbereiden" restart. Login's guided-demo
CTA now actually opens the guide. Fixes a real mobile topbar overflow the
new guide trigger introduced.
2026-08-03 14:15:15 +02:00
NuklearRabbit c63903cc94 docs: record Unraid deployment evidence for demo entry/manifest/badge batch 2026-08-03 13:48:22 +02:00
NuklearRabbit ac427f4427 feat(demo): add demo manifest, Dutch demo entry, permanent badge and About page
Adds GET /api/v1/demo/manifest as a single source of truth for the demo's
fictional org identity (Northstar Mobility -- surfacing the project's
already-locked tenant name), synthetic-data/reset state, and live scenario
readiness. Rewrites the login screen in Dutch with an honest, no-password
demo entry and a guided-demo entry point, replaces the loud full-width
demo banner with a subtle badge + popover, and adds a compact About page
explaining what's real vs. synthetic vs. not yet connected.
2026-08-03 13:45:55 +02:00
NuklearRabbit 728e380d63 docs: record Unraid deployment evidence for the date-anchoring fix 2026-08-03 13:10:33 +02:00
NuklearRabbit 8989ffb23c fix(demo): anchor seeded dates to the real reset moment
Booking/inspection/maintenance/outbox dates were authored as absolute
timestamps around a fixed 2026-08-01 anchor and never re-anchored at
seed/reset time, so demo scenarios (e.g. BK-DEMO-RETURN) silently drifted
into the past. Every reset now shifts seeded dates by (today - authored
anchor); dashboard's "today" filter uses real wall-clock time instead of
the now-removed frozen demo_today setting. Adds seed-validation tests
proving scenarios S1/S2/S4/S5 are present and internally consistent after
every reset.
2026-08-03 13:08:02 +02:00
NuklearRabbit 7c94eb9e87 docs(demo): audit current demo readiness gaps
Confirms the underlying data/business-logic is already demo-grade (Dutch/
Flemish names, .test emails, believable Belgian towns and RV brands; the
5 requested scenarios already exist as S1/S2/S4/S5/S6 in
docs/13-seed-and-demo-scenarios.md) -- the real gaps are structural: no
guided path, no visible fictional org identity (Northstar Mobility is
already the locked tenant name internally, just never shown), a
reproducible date-anchoring bug (seed dates are absolute and don't move
with reset -- BK-DEMO-RETURN's end date is already in the past as of
today), the knowledge page naming "RAGcore" directly instead of "demo
mode", technical-register integration-status labels, and no About page.
2026-08-03 12:54:27 +02:00
NuklearRabbit e0c7ed6011 docs(release): finalize the recorded commit hash 2026-08-02 07:25:30 +02:00
NuklearRabbit 5b2827eb7e docs(release): record the final commit hash in the evidence summary 2026-08-02 07:24:51 +02:00
NuklearRabbit 8a3a43d4ac docs(release): add final functional-completion acceptance evidence 2026-08-02 07:24:37 +02:00
NuklearRabbit ff118dd66d docs(state): record Batch 5 completion 2026-08-02 07:23:08 +02:00
NuklearRabbit 824048b9d4 fix(deploy): mark setup-scheduled-scan.sh executable
Matches the other deploy/unraid/*.sh scripts; was committed 644 instead
of 755, caught while running it directly against the Unraid deployment.
2026-08-02 07:20:48 +02:00
NuklearRabbit c981aad2a3 docs(release): update contracts and docs for functional-completion changes
Add the new endpoints to contracts/openapi.yaml and docs/05-api-contract.md
(return-preview, the four rule-specific data-quality resolution endpoints,
search, integration status, scheduled-scan), document the role matrix and
the audit before/after exposure in docs/12-security-and-audit.md, document
each rule type's actual resolution flow in docs/07-data-quality.md
(including the deliberate evidence-fingerprint simplification and the
reopened_from/previous_decision recurrence link), document the preview/
commit relationship in docs/08-return-workflow.md, and update README.md's
scope/integration-status/quality-gate sections to match what's actually
implemented and verified now. Also drops docs/05-api-contract.md's mention
of GET /api/v1/system/status, which was never implemented.
2026-08-02 07:10:37 +02:00
NuklearRabbit e115031a57 feat(n8n): add scheduled quality-scan workflow
The original docs described two n8n workflows but the repository only ever
shipped one (return-processing); the sketched second workflow (knowledge
sync) depends on RAGcore, which isn't connected here, so it stays deferred.

Add POST /api/v1/integrations/n8n/scheduled-scan (X-Service-Token
protected, same pattern as the return callback), calling the same
run_scan() the manual "Run quality scan" UI action uses and recording a
service-actor data_quality_scan_run audit event. run_scan() already only
creates an issue for a condition without one open, so overlapping triggers
do no duplicate domain work.

n8n/mobilityops-scheduled-quality-scan.json (hourly schedule + manual test
trigger, both feeding the same HTTP call) ships "active": false so it can't
fire anywhere until deliberately published. Verified live against the
local n8n instance via the Manual test trigger: full green execution, and
the resulting data_quality_scan_run audit event (actor_type=service,
actor_label="n8n scheduled scan") confirms the real round trip, not just a
contract test. deploy/unraid/setup-scheduled-scan.sh mirrors the existing
return-workflow publish script for the shared Unraid n8n.
2026-08-02 06:59:17 +02:00
NuklearRabbit ec8f809497 fix(automation): recover stale outbox delivering leases
_claim_due_events flipped rows to 'delivering' and committed before the
HTTP call; if the process died between that commit and the outcome-
recording transaction, the row stayed 'delivering' forever with no reclaim
path -- a real gap, not previously documented as an accepted limitation.

Give each claim a lease deadline (reusing next_attempt_at, since it's only
otherwise meaningful for pending-status backoff scheduling) and sweep
expired leases back to pending at the start of every dispatch cycle, before
claiming new work. attempts is preserved so the count still reflects true
history. Only leases past their deadline are touched, so a still-alive
worker mid-delivery is never disturbed or double-processed.
2026-08-02 06:59:07 +02:00
NuklearRabbit 4a0a4d1cb4 docs(state): record Batch 4 completion 2026-08-02 06:42:54 +02:00
NuklearRabbit 1867828a9d feat(demo): add reset UI and wire truthful integration status into pages
Add a "Reset demo data" action to the sidebar (Operations Manager only,
explicit confirmation, progress, error handling) -- POST /api/v1/demo/reset
already existed and was already role-gated server-side, but had no UI
trigger. Reset invalidates the acting session server-side, so the flow
signs the user out and returns them to login afterward.

Wire the new GET /api/v1/integrations/status into Automation.tsx and
Dashboard.tsx so both show the aggregate n8n state instead of the most
recent event's status, and the MCP Hub card reflects the actual
registration_enabled setting instead of a hardcoded "not configured" label.
2026-08-02 06:42:27 +02:00
NuklearRabbit 4437b8792a feat(search): add role-aware backend search and truthful n8n status
Two new endpoints. GET /api/v1/search returns bounded typed results
(vehicle, booking, data-quality-issue, application section) instead of the
frontend guessing routes from regex patterns against public-ref prefixes;
data-quality and manager-only sections are filtered server-side by role,
and customers are deliberately never returned since no customer detail
route exists in this PoC.

GET /api/v1/integrations/status aggregates outbox delivery counts
(pending/delivering/succeeded/failed) into a single truthful n8n state
(disabled/unavailable/degraded/operational/no_evidence) instead of the UI
showing whichever status the single most recent event happened to be in --
a vehicle_status_conflict-style bug where one stale failure or one lucky
success could misreport the dispatcher's actual health.

Also fixes a real config gap this surfaced: MCP_HUB_REGISTRATION_ENABLED
was documented in .env.example but had no corresponding Settings field, so
it was silently ignored by pydantic-settings' extra="ignore" and never
actually read anywhere in the codebase.
2026-08-02 06:41:56 +02:00
NuklearRabbit 4bc3e33953 docs(state): record Batch 3 completion 2026-08-02 06:16:50 +02:00
NuklearRabbit 477b5e7ce9 feat(quality): add resolution UI for all five rule types and manual scan
DataQualityIssueDetail showed raw JSON as the primary interface for four of
five rule types, with no resolution surface beyond generic defer/reject.
Add a bounded panel per rule type (provide missing fields, retain/correct
an odometer reading, block one of two overlapping bookings, apply the
recommended vehicle status) wired to the new backend endpoints, and move
raw evidence behind a <details> disclosure. Add a "Run quality scan" action
to the workbench (confirmation, progress, per-rule result counts, auto
refresh) -- the endpoint already existed but had no UI trigger.
2026-08-02 06:16:14 +02:00
NuklearRabbit 6e227a214a feat(quality): complete bounded resolution flows and typed snapshots
Two real gaps here: related-entity snapshots were typed by inferring from
the issue's rule_type (get_issue always resolved related refs as "customer"
for duplicates and "vehicle" for everything else), so a booking_overlap
issue's related bookings silently failed to resolve; and defer/reject were
the only resolution actions for 4 of 5 rule types, leaving
missing_required_field, odometer_regression, booking_overlap and
vehicle_status_conflict with no real path beyond a generic reject.

Type related entities from their own public-reference prefix (CUS-/MO-/
BK-/INSP-) instead of the issue's rule_type, and add typed snapshots for
booking and inspection. Add one bounded resolution endpoint per remaining
rule type: provide-fields (re-runs the missing-field check, resolves only
once nothing required is missing), resolve-odometer-regression (retain
canonical or correct the reading -- never silently lowers canonical
mileage), resolve-overlap (blocks one of the two bookings, re-verifies no
overlap remains), apply-recommended-status (one authoritative
recommendation function shared with re-validation). Manual scan now takes
an actor and audits data_quality_scan_run. Reintroduced evidence after a
non-open decision links the new issue back to the prior one
(evidence.reopened_from / previous_decision) instead of looking like a
fresh, undecided problem.
2026-08-02 06:16:07 +02:00
NuklearRabbit 9bd6bea759 docs(state): record Batch 2 completion 2026-08-02 05:34:03 +02:00
NuklearRabbit 7e34f55005 feat(audit): expose structured before/after evidence
audit_events already stored before_json/after_json, but the API and UI only
ever surfaced metadata -- the audit trail could say something happened but
never show what changed. Add before/after to AuditEventOut, resolve a safe
entity_ref/entity_link for vehicle/booking/data-quality-issue entities
(customer stays label-only; no customer detail route exists in this PoC),
and render a human-readable change summary in the UI with the raw
before/after/metadata JSON kept behind a <details> disclosure rather than
shown by default.
2026-08-02 05:33:23 +02:00
NuklearRabbit f5212959b4 feat(returns): add authoritative return preview
The return-review step predicted operational consequences independently in
the frontend, and got it wrong: damage or a technical warning was described
as routing to "maintenance" when the actual domain rule (returns.py) routes
it to "blocked", and the no-contradiction case was described as becoming
"available" when the vehicle actually always goes to "cleaning" first
(only reaching "maintenance" if the service threshold was crossed).

Extract the evaluation returns.py already performed inline into a pure
evaluate_return() function with no writes -- resulting status (with an
explanation), odometer regression, would-create-quality-issue,
next-booking-risk -- and share it between a new non-mutating
POST /bookings/{ref}/return-preview endpoint and the existing commit path,
so preview and commit can never drift apart again. The result screen also
now distinguishes local commit success from n8n delivery (still queued/
unconfirmed) instead of implying both succeeded, and links to any created
quality issue for Operations Manager.
2026-08-02 05:33:12 +02:00
NuklearRabbit 62ac9f825c docs(state): record Batch 1 completion and server verification 2026-08-02 05:00:50 +02:00
NuklearRabbit bdc58f396e fix(e2e): stop hardcoding localhost:8128 for API resets
demo.spec.ts, ui-redesign.spec.ts and interactive-elements.spec.ts all
hardcoded an absolute http://localhost:8128 base for their demo-reset
helpers, which silently pointed at the local dev API even when the suite
was pointed at a different target via MOBILITYOPS_PUBLIC_URL -- discovered
while running the suite against the actual Unraid deployment, where the
reset call kept hitting the local machine instead of the server and left
BK-DEMO-RETURN in whatever state a prior run had left it. Use relative
paths so the request fixture's configured baseURL is honoured everywhere.
2026-08-02 04:59:02 +02:00
NuklearRabbit e1f0ad8431 test(app): cover Batch 1 functional-completion regressions
Add Playwright coverage for the fixes in this batch: vehicle search actually
changes the rendered rows, booking pagination stays within 25 rows and page
2 differs from page 1, session survives a refresh, logout invalidates the
server session, direct navigation without a session redirects to login, and
Rental Employee is blocked from manager-only pages both in the UI (hidden
nav, restricted message) and directly against the API (403).
2026-08-02 04:52:07 +02:00
NuklearRabbit 760f3b6ee2 fix(auth): enforce role boundaries on data quality and audit
The data-quality workbench (list, detail, defer, reject) and the audit trail
had no role gate at all beyond authentication -- confirmed live, a Rental
Employee session could list and resolve data-quality issues and read the
full audit trail through both the API and the UI, with only merge-customers
and scan already restricted.

Per the role matrix, both areas are Operations-Manager-only. Gate the
remaining data-quality and audit endpoints with require_operations_manager,
hide their nav items for Rental Employee, show the same restricted-message
pattern Automation.tsx already used for direct URL access, and stop the
dashboard from linking into now-restricted areas for that role.
2026-08-02 04:52:01 +02:00
NuklearRabbit ffc88e33b4 feat(auth): add server-backed demo sessions
The browser treated sessionStorage as the source of truth for the logged-in
user and never verified or invalidated the server-side session cookie: no
GET /api/v1/demo/session or POST /api/v1/demo/logout endpoint existed, and a
central 401 handler was defined but never wired up.

Add both endpoints; the session-check response is marked Cache-Control:
no-store to avoid the browser serving a stale "authenticated" response right
after logout. AuthProvider now verifies against the server on every mount
(sessionStorage only caches presentation state to avoid a login-screen
flash), subscribes to a central 401 listener on the API client, and
RequireAuth shows a loading state during verification instead of flashing
protected content or the wrong role.
2026-08-02 04:51:54 +02:00
NuklearRabbit 56a65b2364 fix(ui): repair vehicle and booking list filtering and pagination
Vehicles and Bookings both computed a filtered (and, for bookings, paginated)
result but rendered the original unfiltered array in the table body, so
search, status and attention filters had no visible effect and every booking
rendered on every page regardless of the 25-row limit. Render the computed
result instead, and clamp the current booking page when a filter change
shrinks the result set below it.
2026-08-02 04:51:48 +02:00
NuklearRabbit 063a8f9a2d docs(audit): record functional completion findings
Independent audit of the design/mobilityops-premium-ui source and the live
Unraid deployment: confirms the two named list-rendering defects plus
sessionStorage-authoritative auth, a missing role gate on the data-quality
workbench and audit trail, a non-authoritative return preview, raw-JSON
issue evidence, a blind client-side search, single-event integration status,
and an unbounded delivering-lease window in the outbox dispatcher.
2026-08-02 04:51:43 +02:00
NuklearRabbit 938a739dfe docs(deploy): record current Unraid baseline
Capture container topology, deployed revision, migration head, volumes,
network and env-var names on the existing review deployment before any
functional-completion changes, per the audit brief's server-first workflow.
2026-08-02 04:51:43 +02:00
200 changed files with 12911 additions and 862 deletions
+11 -1
View File
@@ -8,8 +8,18 @@ POSTGRES_DB=mobilityops
POSTGRES_USER=mobilityops POSTGRES_USER=mobilityops
POSTGRES_PASSWORD=mobilityops POSTGRES_PASSWORD=mobilityops
APP_SECRET=replace-in-production APP_SECRET=replace-in-production
DEMO_TODAY=2026-08-01
TZ=Europe/Brussels 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
N8N_BASE_URL=http://n8n:5678 N8N_BASE_URL=http://n8n:5678
+8 -1
View File
@@ -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: up:
docker compose up --build -d docker compose up --build -d
@@ -32,6 +32,13 @@ n8n-setup:
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
docker compose restart n8n 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. # Full deterministic demo bootstrap: build, migrate (automatic on api startup), seed.
demo: up demo: up
docker compose exec api python -m app.cli seed --reset docker compose exec api python -m app.cli seed --reset
+498
View File
@@ -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, - 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 shared-n8n topology and evidence are committed, pushed and deployed; do not merge master
automatically. 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.
+44 -13
View File
@@ -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. 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 ## Scope
The PoC implements: The PoC implements:
- operations dashboard; - operations dashboard with a truthful aggregate n8n/MCP integration-status card;
- vehicle and booking views; - vehicle and booking views with working search, filters and pagination;
- one complete vehicle-return workflow; - server-backed session lifecycle (refresh-safe, central 401 handling);
- five deterministic data-quality checks; - 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; - 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; - 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; - four read-only MCP tools through ITWorx MCP Hub;
- deterministic demo reset and five-minute showcase. - 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 - **n8n**: fully implemented and verified against a real n8n instance, including
degraded mode (n8n stopped mid-flow → return still commits, event stays `pending` 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 - **RAGcore**: the demo `KnowledgeProvider` (deterministic TF-IDF extractive retrieval
over the local procedure documents) is what satisfies the knowledge-assistant over the local procedure documents) is what satisfies the knowledge-assistant
acceptance criteria and is fully verified. A `RAGcoreKnowledgeProvider` HTTP adapter is acceptance criteria and is fully verified. A `RAGcoreKnowledgeProvider` HTTP adapter is
implemented and unit-tested, including its unavailable-degradation path, but was never implemented and unit-tested, including its unavailable-degradation path, but was never
exercised against a live RAGcore instance in this environment. exercised against a live RAGcore instance in this environment.
- **ITWorx MCP Hub**: the four read-only provider endpoints are implemented, tested, and - **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 directly `curl`-verified with correct auth enforcement and audit logging.
instance was reachable in this environment to verify an actual Hub round trip. `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 See `artifacts/functional-completion/final-summary.md` for the functional-completion
commands. audit evidence (supersedes the design-validation summary below for integration status),
and `artifacts/final-acceptance/summary.md` for the original M0M7 acceptance evidence.
## Repository map ## Repository map
@@ -60,6 +86,11 @@ commands.
- `frontend/` — React/TypeScript/Vite web app, including the Playwright end-to-end suite (`frontend/e2e/`). - `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/evidence/` — final acceptance evidence (screenshots, architecture, `final-summary.md`).
- `artifacts/design-validation/` — baseline audit, Stitch direction references and implemented responsive captures. - `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 ## Quickstart
@@ -83,9 +114,9 @@ All defaults are configurable via `.env` (see `.env.example`).
## Quality gates ## Quality gates
```bash ```bash
make test # backend: pytest (66 tests) make test # backend: pytest (127 tests)
make lint # backend: ruff + mypy (strict, zero errors) 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`). Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`).
+195
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

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 M0M7/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.
+55 -5
View File
@@ -1,15 +1,55 @@
from __future__ import annotations from __future__ import annotations
import uuid
from collections.abc import Sequence
from typing import Any
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session 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.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 from app.schemas import AuditEventOut, CurrentUser
router = APIRouter(prefix="/api/v1/audit", tags=["audit"]) 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]) @router.get("", response_model=list[AuditEventOut])
def list_audit_events( def list_audit_events(
@@ -19,7 +59,7 @@ def list_audit_events(
correlation_id: str | None = Query(default=None), correlation_id: str | None = Query(default=None),
limit: int = Query(default=100, le=500), limit: int = Query(default=100, le=500),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user), _user: CurrentUser = Depends(require_operations_manager),
) -> list[AuditEventOut]: ) -> list[AuditEventOut]:
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit) stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit)
if actor_label: if actor_label:
@@ -31,7 +71,13 @@ def list_audit_events(
if correlation_id: if correlation_id:
stmt = stmt.where(AuditEvent.correlation_id == correlation_id) stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
events = db.scalars(stmt).all() 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( AuditEventOut(
id=str(e.id), id=str(e.id),
actor_type=e.actor_type, actor_type=e.actor_type,
@@ -39,9 +85,13 @@ def list_audit_events(
action=e.action, action=e.action,
entity_type=e.entity_type, entity_type=e.entity_type,
entity_id=str(e.entity_id) if e.entity_id else None, 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), correlation_id=str(e.correlation_id),
occurred_at=e.occurred_at, occurred_at=e.occurred_at,
before=e.before_json,
after=e.after_json,
metadata=e.metadata_json, metadata=e.metadata_json,
) )
for e in events )
] return out
+35 -2
View File
@@ -8,8 +8,14 @@ from app.api.deps import get_current_user, get_db
from app.models.booking import Booking from app.models.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import BookingOut, CurrentUser, RegisterReturnRequest from app.schemas import (
from app.services.returns import register_vehicle_return BookingOut,
CurrentUser,
NextBookingRisk,
RegisterReturnRequest,
ReturnPreviewResult,
)
from app.services.returns import preview_vehicle_return, register_vehicle_return
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"]) router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
@@ -66,6 +72,33 @@ def get_booking(
return _to_out(booking, customer, vehicle) 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") @router.post("/{public_ref}/return")
def register_return( def register_return(
public_ref: str, public_ref: str,
+5 -4
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime from datetime import UTC, date, datetime
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -30,7 +30,9 @@ _SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
def _today() -> date: 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) @router.get("", response_model=DashboardOut)
@@ -59,12 +61,11 @@ def get_dashboard(
entity = customers_by_id.get(issue.entity_id) entity = customers_by_id.get(issue.entity_id)
link_type = "customer" link_type = "customer"
link_ref = entity.public_ref if entity else "" link_ref = entity.public_ref if entity else ""
title = f"{issue.rule_type.replace('_', ' ').title()}{link_ref}"
attention_items.append( attention_items.append(
AttentionItem( AttentionItem(
kind="quality_issue", kind="quality_issue",
severity=issue.severity, severity=issue.severity,
title=title, rule_type=issue.rule_type,
detail=issue.evidence_json.get("summary", ""), detail=issue.evidence_json.get("summary", ""),
link_type=link_type, link_type=link_type,
link_ref=link_ref, link_ref=link_ref,
+134 -16
View File
@@ -4,19 +4,34 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session 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.customer import Customer
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
from app.models.inspection import Inspection
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import ( from app.schemas import (
ApplyRecommendedStatusResult,
CurrentUser, CurrentUser,
DataQualityIssueDetailOut, DataQualityIssueDetailOut,
DataQualityIssueOut, DataQualityIssueOut,
MergeCustomersRequest, MergeCustomersRequest,
MergeCustomersResult, MergeCustomersResult,
ProvideFieldsRequest,
ResolveOdometerRegressionRequest,
ResolveOverlapRequest,
ScanResultOut, 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"]) router = APIRouter(prefix="/api/v1/data-quality", tags=["data-quality"])
@@ -41,7 +56,7 @@ def list_issues(
rule_type: str | None = Query(default=None), rule_type: str | None = Query(default=None),
severity: str | None = Query(default=None), severity: str | None = Query(default=None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user), _user: CurrentUser = Depends(require_operations_manager),
) -> list[DataQualityIssueOut]: ) -> list[DataQualityIssueOut]:
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc()) stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
if status: if status:
@@ -54,12 +69,33 @@ def list_issues(
return [_to_out(i) for i in 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: def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
if entity_type == "customer": if entity_type == "customer":
customer = db.scalar(select(Customer).where(Customer.public_ref == ref)) customer = db.scalar(select(Customer).where(Customer.public_ref == ref))
if customer is None: if customer is None:
return None return None
return { return {
"entity_type": "customer",
"public_ref": customer.public_ref, "public_ref": customer.public_ref,
"first_name": customer.first_name, "first_name": customer.first_name,
"last_name": customer.last_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, "postal_code": customer.postal_code,
"city": customer.city, "city": customer.city,
} }
if entity_type == "vehicle":
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == ref)) vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == ref))
if vehicle is None: if vehicle is None:
return None return None
return { return {
"entity_type": "vehicle",
"public_ref": vehicle.public_ref, "public_ref": vehicle.public_ref,
"make": vehicle.make, "make": vehicle.make,
"model": vehicle.model, "model": vehicle.model,
@@ -79,30 +117,61 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
"operational_status": vehicle.operational_status, "operational_status": vehicle.operational_status,
"odometer_km": vehicle.odometer_km, "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) @router.get("/issues/{public_ref}", response_model=DataQualityIssueDetailOut)
def get_issue( def get_issue(
public_ref: str, public_ref: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user), _user: CurrentUser = Depends(require_operations_manager),
) -> DataQualityIssueDetailOut: ) -> DataQualityIssueDetailOut:
issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)) issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref))
if issue is None: if issue is None:
raise HTTPException(status_code=404, detail="Data quality issue not found") raise HTTPException(status_code=404, detail="Data quality issue not found")
base = _to_out(issue) base = _to_out(issue)
related_refs = issue.evidence_json.get("related_refs", []) related_refs = issue.evidence_json.get("related_refs", [])
related_entity_type = ( related_snapshots = []
"customer" if issue.rule_type == "possible_duplicate_customer" else "vehicle" 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( return DataQualityIssueDetailOut(
**base.model_dump(), **base.model_dump(),
entity_snapshot=_snapshot(issue.entity_type, base.entity_ref, db), entity_snapshot=_snapshot(issue.entity_type, base.entity_ref, db),
related_snapshots=[ related_snapshots=related_snapshots,
snap
for ref in related_refs
if (snap := _snapshot(related_entity_type, ref, db)) is not None
],
) )
@@ -110,7 +179,7 @@ def get_issue(
def defer( def defer(
public_ref: str, public_ref: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(require_operations_manager),
) -> DataQualityIssueOut: ) -> DataQualityIssueOut:
issue = defer_issue(db, public_ref, user) issue = defer_issue(db, public_ref, user)
return _to_out(issue) return _to_out(issue)
@@ -120,7 +189,7 @@ def defer(
def reject( def reject(
public_ref: str, public_ref: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(require_operations_manager),
) -> DataQualityIssueOut: ) -> DataQualityIssueOut:
issue = reject_issue(db, public_ref, user) issue = reject_issue(db, public_ref, user)
return _to_out(issue) return _to_out(issue)
@@ -137,10 +206,59 @@ def merge(
return MergeCustomersResult(**result) 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) @router.post("/scan", response_model=ScanResultOut)
def scan( def scan(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager), user: CurrentUser = Depends(require_operations_manager),
) -> ScanResultOut: ) -> ScanResultOut:
result = run_scan(db) result = run_scan(db, actor_label=user.display_name, actor_type="user")
return ScanResultOut(created=result.created) return ScanResultOut(created=result.created)
+61 -6
View File
@@ -1,23 +1,33 @@
from __future__ import annotations from __future__ import annotations
import time 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 import select
from sqlalchemy.orm import Session 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.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.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.seed_loader import reset_and_seed
from app.services.audit import record_audit_event 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"]) router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
settings = get_settings() 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) @router.post("/login", response_model=CurrentUser)
def demo_login( def demo_login(
body: DemoLoginRequest, response: Response, db: Session = Depends(get_db) body: DemoLoginRequest, response: Response, db: Session = Depends(get_db)
@@ -41,6 +51,7 @@ def demo_login(
token, token,
httponly=True, httponly=True,
samesite="lax", samesite="lax",
secure=settings.session_cookie_secure,
max_age=settings.session_ttl_seconds, max_age=settings.session_ttl_seconds,
) )
record_audit_event( 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) 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") @router.post("/reset")
def demo_reset( def demo_reset(
response: Response, response: Response,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager), user: CurrentUser = Depends(require_operations_manager),
) -> dict: ) -> 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) result = reset_and_seed(db)
integrity = scenario_integrity_report(db)
record_audit_event( record_audit_event(
db, db,
actor_type="user", actor_type="user",
actor_label=user.display_name, actor_label=user.display_name,
action="demo_reset", action="demo_reset",
entity_type="system", entity_type="system",
metadata={"counts": result.counts}, metadata={
"counts": result.counts,
"anchor_date": result.anchor_date.isoformat(),
"scenario_integrity": integrity,
},
) )
db.commit() db.commit()
response.delete_cookie(settings.session_cookie_name) 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",
),
)
+18
View File
@@ -13,7 +13,9 @@ from app.core.config import get_settings
from app.core.errors import AppError from app.core.errors import AppError
from app.models.audit import AuditEvent from app.models.audit import AuditEvent
from app.models.outbox import OutboxEvent from app.models.outbox import OutboxEvent
from app.schemas import ScanResultOut
from app.services.audit import record_audit_event 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"]) router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"])
settings = get_settings() settings = get_settings()
@@ -71,3 +73,19 @@ def return_callback(
"event_id": str(event_id), "event_id": str(event_id),
"occurred_at": datetime.now(UTC).isoformat(), "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)
+11 -3
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from typing import Literal
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field 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"]) router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"]
class AskQuestionRequest(BaseModel): class AskQuestionRequest(BaseModel):
question: str = Field(min_length=3, max_length=1000) question: str = Field(min_length=3, max_length=1000)
language: SupportedLanguage = "en-GB"
@router.post("/questions", response_model=GroundedAnswer) @router.post("/questions", response_model=GroundedAnswer)
@@ -26,7 +30,7 @@ def ask_question(
) -> GroundedAnswer: ) -> GroundedAnswer:
correlation_id = str(uuid.uuid4()) correlation_id = str(uuid.uuid4())
provider = get_knowledge_provider() provider = get_knowledge_provider()
answer = provider.ask(body.question, correlation_id) answer = provider.ask(body.question, correlation_id, body.language)
record_audit_event( record_audit_event(
db, db,
@@ -40,6 +44,7 @@ def ask_question(
"provider": answer.provider, "provider": answer.provider,
"source_ids": [s.document_id for s in answer.sources], "source_ids": [s.document_id for s in answer.sources],
"question_length": len(body.question), "question_length": len(body.question),
"language": body.language,
}, },
) )
db.commit() db.commit()
@@ -47,5 +52,8 @@ def ask_question(
@router.get("/status", response_model=KnowledgeHealth) @router.get("/status", response_model=KnowledgeHealth)
def knowledge_status(_user: CurrentUser = Depends(get_current_user)) -> KnowledgeHealth: def knowledge_status(
return get_knowledge_provider().health() language: SupportedLanguage = "en-GB",
_user: CurrentUser = Depends(get_current_user),
) -> KnowledgeHealth:
return get_knowledge_provider().health(language)
+146
View File
@@ -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])
+6 -1
View File
@@ -22,14 +22,19 @@ class Settings(BaseSettings):
n8n_dispatch_interval_seconds: float = 3.0 n8n_dispatch_interval_seconds: float = 3.0
n8n_http_timeout_seconds: float = 5.0 n8n_http_timeout_seconds: float = 5.0
n8n_max_attempts: int = 5 n8n_max_attempts: int = 5
n8n_delivery_lease_seconds: float = 120.0
app_secret: str = "replace-in-production" app_secret: str = "replace-in-production"
session_cookie_name: str = "mobilityops_session" session_cookie_name: str = "mobilityops_session"
session_ttl_seconds: int = 60 * 60 * 8 session_ttl_seconds: int = 60 * 60 * 8
session_cookie_secure: bool = False
seed_dir: str = "/app/seed" seed_dir: str = "/app/seed"
knowledge_dir: str = "/app/knowledge/procedures" knowledge_dir: str = "/app/knowledge/procedures"
mcp_hub_service_token: str = "replace-me-mcp-hub-token" mcp_hub_service_token: str = "replace-me-mcp-hub-token"
mcp_hub_registration_enabled: bool = False
cors_allow_origins: str = "http://localhost:1228" 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 @lru_cache
+4
View File
@@ -11,9 +11,11 @@ from app.api.routers import (
dashboard, dashboard,
data_quality, data_quality,
demo, demo,
integration_status,
integrations, integrations,
knowledge, knowledge,
mcp_integrations, mcp_integrations,
search,
vehicles, vehicles,
workflows, workflows,
) )
@@ -88,3 +90,5 @@ app.include_router(workflows.router)
app.include_router(integrations.router) app.include_router(integrations.router)
app.include_router(knowledge.router) app.include_router(knowledge.router)
app.include_router(mcp_integrations.router) app.include_router(mcp_integrations.router)
app.include_router(search.router)
app.include_router(integration_status.router)
+106 -1
View File
@@ -74,6 +74,20 @@ class RegisterReturnResult(BaseModel):
next_booking_risk: NextBookingRisk | None 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): class InspectionOut(BaseModel):
public_ref: str public_ref: str
booking_ref: str booking_ref: str
@@ -127,6 +141,93 @@ class ScanResultOut(BaseModel):
created: dict[str, int] 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): class VehicleDetailOut(VehicleOut):
bookings: list[BookingSummaryOut] = Field(default_factory=list) bookings: list[BookingSummaryOut] = Field(default_factory=list)
inspections: list[InspectionOut] = Field(default_factory=list) inspections: list[InspectionOut] = Field(default_factory=list)
@@ -147,7 +248,7 @@ class DashboardMetrics(BaseModel):
class AttentionItem(BaseModel): class AttentionItem(BaseModel):
kind: Literal["quality_issue", "vehicle"] kind: Literal["quality_issue", "vehicle"]
severity: str severity: str
title: str rule_type: str
detail: str detail: str
link_type: Literal["vehicle", "booking", "customer"] link_type: Literal["vehicle", "booking", "customer"]
link_ref: str link_ref: str
@@ -216,6 +317,10 @@ class AuditEventOut(BaseModel):
action: str action: str
entity_type: str entity_type: str
entity_id: str | None entity_id: str | None
entity_ref: str | None = None
entity_link: str | None = None
correlation_id: str correlation_id: str
occurred_at: datetime occurred_at: datetime
before: dict[str, Any] | None = None
after: dict[str, Any] | None = None
metadata: dict[str, Any] | None = None metadata: dict[str, Any] | None = None
+37 -7
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import csv import csv
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, date, datetime, timedelta
from pathlib import Path from pathlib import Path
from sqlalchemy import delete, insert, update 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.outbox import OutboxEvent
from app.models.user import User from app.models.user import User
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.services.audit import record_audit_event
settings = get_settings() 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: def _parse_dt(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00")) return datetime.fromisoformat(value.replace("Z", "+00:00"))
@@ -53,6 +65,8 @@ def _parse_optional_int(value: str) -> int | None:
@dataclass @dataclass
class SeedResult: class SeedResult:
counts: dict[str, int] counts: dict[str, int]
anchor_date: date
seeded_at: datetime
def _seed_dir() -> Path: def _seed_dir() -> Path:
@@ -83,6 +97,8 @@ def clear_all(db: Session) -> None:
def load_seed(db: Session) -> SeedResult: def load_seed(db: Session) -> SeedResult:
counts: dict[str, int] = {} counts: dict[str, int] = {}
today = datetime.now(UTC).date()
shift = _seed_anchor_shift(today)
user_rows = [ user_rows = [
{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS {"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"], "public_ref": row["public_ref"],
"customer_id": customer_id_by_ref[row["customer_ref"]], "customer_id": customer_id_by_ref[row["customer_ref"]],
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]], "vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
"starts_at": _parse_dt(row["starts_at"]), "starts_at": _parse_dt(row["starts_at"]) + shift,
"ends_at": _parse_dt(row["ends_at"]), "ends_at": _parse_dt(row["ends_at"]) + shift,
"status": row["status"], "status": row["status"],
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]), "start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
"end_odometer_km": _parse_optional_int(row["end_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"]), "damage_reported": _parse_bool(row["damage_reported"]),
"technical_warning": _parse_bool(row["technical_warning"]), "technical_warning": _parse_bool(row["technical_warning"]),
"odometer_km": int(row["odometer_km"]), "odometer_km": int(row["odometer_km"]),
"completed_at": _parse_dt(row["completed_at"]), "completed_at": _parse_dt(row["completed_at"]) + shift,
"completed_by": None, "completed_by": None,
} }
) )
@@ -193,7 +209,7 @@ def load_seed(db: Session) -> SeedResult:
"id": uuid.uuid4(), "id": uuid.uuid4(),
"public_ref": row["public_ref"], "public_ref": row["public_ref"],
"vehicle_id": vehicle_id_by_ref[row["vehicle_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"]), "odometer_km": int(row["odometer_km"]),
"category": row["category"], "category": row["category"],
"summary": row["summary"], "summary": row["summary"],
@@ -266,7 +282,7 @@ def load_seed(db: Session) -> SeedResult:
}, },
"aggregate_ref": row["aggregate_ref"], "aggregate_ref": row["aggregate_ref"],
}, },
"occurred_at": _parse_dt(row["occurred_at"]), "occurred_at": _parse_dt(row["occurred_at"]) + shift,
"delivery_status": row["status"], "delivery_status": row["status"],
"attempts": int(row["attempts"]), "attempts": int(row["attempts"]),
"next_attempt_at": None, "next_attempt_at": None,
@@ -277,7 +293,21 @@ def load_seed(db: Session) -> SeedResult:
db.execute(insert(OutboxEvent), outbox_rows) db.execute(insert(OutboxEvent), outbox_rows)
counts["workflow_runs"] = len(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: def reset_and_seed(db: Session) -> SeedResult:
+421 -7
View File
@@ -13,7 +13,7 @@ from app.models.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
from app.models.vehicle import Vehicle 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 from app.services.audit import record_audit_event
REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name") 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): if _has_open_issue(db, rule_type, entity_type, entity_id):
return return
now = datetime.now(UTC) 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( issue = DataQualityIssue(
public_ref=_next_public_ref(db, "DQ-SCAN"), public_ref=_next_public_ref(db, "DQ-SCAN"),
rule_type=rule_type, rule_type=rule_type,
@@ -80,11 +103,7 @@ def _open_issue(
entity_id=entity_id, entity_id=entity_id,
severity=severity, severity=severity,
status="open", status="open",
evidence_json={ evidence_json=evidence,
"summary": summary,
"entity_ref": entity_ref,
"related_refs": related_refs,
},
proposed_action_json={}, proposed_action_json={},
detected_at=now, detected_at=now,
) )
@@ -280,13 +299,24 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
break 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 = ScanResult()
_scan_duplicate_customers(db, scan) _scan_duplicate_customers(db, scan)
_scan_missing_required_fields(db, scan) _scan_missing_required_fields(db, scan)
_scan_odometer_regressions(db, scan) _scan_odometer_regressions(db, scan)
_scan_booking_overlaps(db, scan) _scan_booking_overlaps(db, scan)
_scan_vehicle_status_conflicts(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() db.commit()
return scan return scan
@@ -344,6 +374,390 @@ def reject_issue(db: Session, public_ref: str, actor: CurrentUser) -> DataQualit
return issue 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") MERGEABLE_FIELDS = ("first_name", "last_name", "email", "phone", "postal_code", "city")
+189
View File
@@ -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),
)
+39 -2
View File
@@ -22,8 +22,42 @@ def _backoff_seconds(attempts: int) -> int:
return min(2**attempts, 60) 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]: 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() db = SessionLocal()
try: try:
now = datetime.now(UTC) 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) .with_for_update(skip_locked=True)
).all() ).all()
claimed_ids = [row.event_id for row in rows] claimed_ids = [row.event_id for row in rows]
lease_deadline = now + timedelta(seconds=settings.n8n_delivery_lease_seconds)
for row in rows: for row in rows:
row.delivery_status = "delivering" row.delivery_status = "delivering"
row.next_attempt_at = lease_deadline
db.commit() db.commit()
return claimed_ids return claimed_ids
finally: finally:
@@ -120,7 +156,8 @@ def _deliver_one(event_id: uuid.UUID) -> None:
def run_dispatch_cycle() -> int: 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() claimed = _claim_due_events()
for event_id in claimed: for event_id in claimed:
_deliver_one(event_id) _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,
)
+4 -2
View File
@@ -39,9 +39,11 @@ class KnowledgeHealth(BaseModel):
class KnowledgeProvider(Protocol): class KnowledgeProvider(Protocol):
name: str 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 @lru_cache
+100 -33
View File
@@ -8,12 +8,31 @@ from pathlib import Path
from app.core.config import get_settings from app.core.config import get_settings
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard 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", "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
"to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then", "to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then",
"do", "does", "did", "must", "may", "can", "could", "should", "would", "do", "does", "did", "must", "may", "can", "could", "should", "would",
"i", "you", "it", "we", "they", "my", "your", "what", "when", "how", "i", "you", "it", "we", "they", "my", "your", "what", "when", "how",
"with", "without", "this", "that", "these", "those", "not", "no", "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]+") _WORD_RE = re.compile(r"[a-z0-9]+")
@@ -28,9 +47,10 @@ def _stem(word: str) -> str:
return word 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()) 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 @dataclass
@@ -86,7 +106,7 @@ def _split_sections(body: str) -> list[tuple[str, str]]:
return sections return sections
def _load_sections(procedures_dir: Path) -> list[ScoredSection]: def _load_sections(procedures_dir: Path, language: str) -> list[ScoredSection]:
sections: list[ScoredSection] = [] sections: list[ScoredSection] = []
for path in sorted(procedures_dir.glob("*.md")): for path in sorted(procedures_dir.glob("*.md")):
raw = path.read_text(encoding="utf-8") 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), document_id=meta.get("document_id", path.stem),
title=title, title=title,
version=meta.get("version", "1.0"), version=meta.get("version", "1.0"),
title_tokens=_tokenize(title), title_tokens=_tokenize(title, language),
) )
for heading, text in _split_sections(body): for heading, text in _split_sections(body):
sections.append( sections.append(
@@ -104,19 +124,49 @@ def _load_sections(procedures_dir: Path) -> list[ScoredSection]:
document=doc, document=doc,
heading=heading, heading=heading,
text=text, text=text,
heading_tokens=_tokenize(heading), heading_tokens=_tokenize(heading, language),
body_tokens=_tokenize(text), body_tokens=_tokenize(text, language),
) )
) )
return sections 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: class DemoKnowledgeProvider:
"""Deterministic extractive retrieval over the local procedure Markdown files. """Deterministic extractive retrieval over the local procedure Markdown files.
Not a generative model: it scores sections with TF-IDF-weighted keyword overlap 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 (downweighting terms common across the whole corpus, like "vehicle", in favor of
distinctive ones, like "damage") and returns real excerpts, never invented text. 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" name = "demo"
@@ -124,10 +174,18 @@ class DemoKnowledgeProvider:
def __init__(self) -> None: def __init__(self) -> None:
settings = get_settings() settings = get_settings()
self._settings = settings self._settings = settings
self._procedures_dir = Path(settings.knowledge_dir) base_dir = Path(settings.knowledge_dir)
self._sections = _load_sections(self._procedures_dir) self._sections_by_language: dict[str, list[ScoredSection]] = {}
self._document_count = len({s.document.document_id for s in self._sections}) self._idf_by_language: dict[str, dict[str, float]] = {}
self._idf = self._build_idf(self._sections) 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 @staticmethod
def _build_idf(sections: list[ScoredSection]) -> dict[str, float]: def _build_idf(sections: list[ScoredSection]) -> dict[str, float]:
@@ -140,7 +198,13 @@ class DemoKnowledgeProvider:
doc_freq[token] = doc_freq.get(token, 0) + 1 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()} 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( return KnowledgeHealth(
provider=self.name, provider=self.name,
available=True, available=True,
@@ -148,36 +212,40 @@ class DemoKnowledgeProvider:
tenant=self._settings.ragcore_tenant, tenant=self._settings.ragcore_tenant,
workspace=self._settings.ragcore_workspace, workspace=self._settings.ragcore_workspace,
collection=self._settings.ragcore_collection, 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 score = 0.0
for token in query_tokens: for token in query_tokens:
idf = self._idf.get(token, 0.0) token_idf = idf.get(token, 0.0)
if idf == 0.0: if token_idf == 0.0:
continue continue
if token in section.heading_tokens: if token in section.heading_tokens:
score += 3 * idf score += 3 * token_idf
elif token in section.document.title_tokens: elif token in section.document.title_tokens:
score += 2 * idf score += 2 * token_idf
elif token in section.body_tokens: elif token in section.body_tokens:
score += idf score += token_idf
return score return score
def ask(self, question: str, correlation_id: str) -> GroundedAnswer: def ask(
query_tokens = _tokenize(question) self, question: str, correlation_id: str, language: str = DEFAULT_LANGUAGE
scored = [ ) -> GroundedAnswer:
(self._score(query_tokens, section), section) language = self._normalize_language(language)
for section in self._sections 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 = [(score, section) for score, section in scored if score > 0]
scored.sort(key=lambda item: item[0], reverse=True) scored.sort(key=lambda item: item[0], reverse=True)
top = scored[:3] top = scored[:3]
if not top: if not top:
return GroundedAnswer( return GroundedAnswer(
answer="No matching procedure was found for this question.", answer=_NO_MATCH_TEXT[language],
evidence_state="insufficient", evidence_state="insufficient",
sources=[], sources=[],
provider=self.name, provider=self.name,
@@ -197,10 +265,7 @@ class DemoKnowledgeProvider:
if top[0][0] < 3: if top[0][0] < 3:
return GroundedAnswer( return GroundedAnswer(
answer=( answer=_LOW_CONFIDENCE_TEXT[language],
"The available procedures do not clearly answer this question. "
"The closest matches are included below for review."
),
evidence_state="insufficient", evidence_state="insufficient",
sources=sources, sources=sources,
provider=self.name, provider=self.name,
@@ -208,9 +273,11 @@ class DemoKnowledgeProvider:
) )
lead_section = top[0][1] lead_section = top[0][1]
answer = ( answer = _LEAD_ANSWER_TEMPLATE[language].format(
f'Per "{lead_section.document.title}" (v{lead_section.document.version}), ' title=lead_section.document.title,
f'section "{lead_section.heading}": {lead_section.text.splitlines()[0][:300]}' version=lead_section.document.version,
heading=lead_section.heading,
excerpt=lead_section.text.splitlines()[0][:300],
) )
return GroundedAnswer( return GroundedAnswer(
answer=answer, answer=answer,
+3 -2
View File
@@ -32,7 +32,7 @@ class RAGcoreKnowledgeProvider:
timeout=self._settings.ragcore_http_timeout_seconds, timeout=self._settings.ragcore_http_timeout_seconds,
) )
def health(self) -> KnowledgeHealth: def health(self, language: str = "en-GB") -> KnowledgeHealth:
try: try:
with self._client() as client: with self._client() as client:
response = client.get("/health") response = client.get("/health")
@@ -52,7 +52,7 @@ class RAGcoreKnowledgeProvider:
document_count=0, 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: try:
with self._client() as client: with self._client() as client:
response = client.post( response = client.post(
@@ -63,6 +63,7 @@ class RAGcoreKnowledgeProvider:
"collection": self._settings.ragcore_collection, "collection": self._settings.ragcore_collection,
"question": question, "question": question,
"correlation_id": correlation_id, "correlation_id": correlation_id,
"language": language,
}, },
) )
response.raise_for_status() response.raise_for_status()
+123 -50
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
@@ -25,12 +26,120 @@ def _next_public_ref(db: Session) -> str:
return f"{REF_PREFIX}-{len(existing) + 1:04d}" return f"{REF_PREFIX}-{len(existing) + 1:04d}"
def _derive_vehicle_status(body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int) -> str: def _derive_vehicle_status_with_reason(
if body.damage_reported or body.technical_warning: body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int
return "blocked" ) -> 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: if new_odometer >= vehicle.next_service_km:
return "maintenance" return (
return "cleaning" "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( def register_vehicle_return(
@@ -53,14 +162,7 @@ def register_vehicle_return(
) )
return existing.response_status, existing.response_body return existing.response_status, existing.response_body
booking = db.scalar(select(Booking).where(Booking.public_ref == booking_ref).with_for_update()) booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True)
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
)
# Re-check after acquiring the row lock: a concurrent identical-key request may have # Re-check after acquiring the row lock: a concurrent identical-key request may have
# just committed while we were waiting. # just committed while we were waiting.
@@ -79,6 +181,7 @@ def register_vehicle_return(
now = datetime.now(UTC) now = datetime.now(UTC)
correlation_id = uuid.uuid4() correlation_id = uuid.uuid4()
evaluation = evaluate_return(db, booking, vehicle, body, now=now)
inspection = Inspection( inspection = Inspection(
public_ref=_next_public_ref(db), public_ref=_next_public_ref(db),
@@ -104,13 +207,8 @@ def register_vehicle_return(
booking.status = "returned" booking.status = "returned"
booking.end_odometer_km = body.end_odometer_km booking.end_odometer_km = body.end_odometer_km
odometer_regression = body.end_odometer_km < vehicle.odometer_km
quality_issue_ref: str | None = None quality_issue_ref: str | None = None
canonical_odometer = vehicle.odometer_km if evaluation.odometer_regression:
if not odometer_regression:
canonical_odometer = body.end_odometer_km
vehicle.odometer_km = canonical_odometer
else:
issue = DataQualityIssue( issue = DataQualityIssue(
public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}", public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}",
rule_type="odometer_regression", rule_type="odometer_regression",
@@ -121,7 +219,7 @@ def register_vehicle_return(
evidence_json={ evidence_json={
"summary": ( "summary": (
f"Return submitted {body.end_odometer_km} km, below canonical " 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, "entity_ref": vehicle.public_ref,
"related_refs": [booking.public_ref, inspection.public_ref], "related_refs": [booking.public_ref, inspection.public_ref],
@@ -133,7 +231,8 @@ def register_vehicle_return(
db.flush() db.flush()
quality_issue_ref = issue.public_ref 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.operational_status = resulting_status
vehicle.version += 1 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 = OutboxEvent(
event_id=uuid.uuid4(), event_id=uuid.uuid4(),
event_type="vehicle.returned.v1", event_type="vehicle.returned.v1",
@@ -189,7 +280,7 @@ def register_vehicle_return(
"vehicle_ref": vehicle.public_ref, "vehicle_ref": vehicle.public_ref,
"inspection_ref": inspection.public_ref, "inspection_ref": inspection.public_ref,
"resulting_vehicle_status": resulting_status, "resulting_vehicle_status": resulting_status,
"attention_reasons": attention_reasons, "attention_reasons": evaluation.attention_reasons,
}, },
"aggregate_ref": booking.public_ref, "aggregate_ref": booking.public_ref,
}, },
@@ -199,33 +290,15 @@ def register_vehicle_return(
) )
db.add(event) 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 = { response_body = {
"booking_ref": booking.public_ref, "booking_ref": booking.public_ref,
"vehicle_ref": vehicle.public_ref, "vehicle_ref": vehicle.public_ref,
"inspection_ref": inspection.public_ref, "inspection_ref": inspection.public_ref,
"resulting_vehicle_status": resulting_status, "resulting_vehicle_status": resulting_status,
"odometer_regression": odometer_regression, "odometer_regression": evaluation.odometer_regression,
"quality_issue_ref": quality_issue_ref, "quality_issue_ref": quality_issue_ref,
"workflow_event_id": str(event.event_id), "workflow_event_id": str(event.event_id),
"next_booking_risk": next_booking_risk, "next_booking_risk": evaluation.next_booking_risk,
} }
db.add( db.add(
+63
View File
@@ -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): def test_demo_login_is_audited(ops_client):
response = ops_client.get("/api/v1/audit", params={"action": "demo_login"}) response = ops_client.get("/api/v1/audit", params={"action": "demo_login"})
assert response.status_code == 200 assert response.status_code == 200
@@ -9,3 +16,59 @@ def test_demo_login_is_audited(ops_client):
def test_audit_requires_authentication(client): def test_audit_requires_authentication(client):
response = client.get("/api/v1/audit") response = client.get("/api/v1/audit")
assert response.status_code == 401 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"]
+48 -1
View File
@@ -17,4 +17,51 @@ def test_rental_employee_cannot_reset_demo(employee_client):
def test_operations_manager_can_reset_demo(ops_client): def test_operations_manager_can_reset_demo(ops_client):
response = ops_client.post("/api/v1/demo/reset") response = ops_client.post("/api/v1/demo/reset")
assert response.status_code == 200 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
+282
View File
@@ -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): def test_list_includes_all_five_rule_types(ops_client):
response = ops_client.get("/api/v1/data-quality/issues") response = ops_client.get("/api/v1/data-quality/issues")
assert response.status_code == 200 assert response.status_code == 200
@@ -25,6 +48,26 @@ def test_scan_requires_operations_manager(employee_client):
assert response.status_code == 403 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): def test_s2_duplicate_customer_issue_detail(ops_client):
response = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE") response = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
assert response.status_code == 200 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"}, json={"survivor_ref": "CUS-0012"},
) )
assert replay.status_code == 409 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"
+54
View File
@@ -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"
+78 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from types import SimpleNamespace from types import SimpleNamespace
from sqlalchemy import select 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 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): def test_run_dispatch_cycle_end_to_end(monkeypatch):
event_id = _make_pending_event("MO-005") event_id = _make_pending_event("MO-005")
+41
View File
@@ -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"
+40
View File
@@ -82,3 +82,43 @@ def test_callback_is_idempotent_by_event_id(client, ops_client):
).json() ).json()
matching = [e for e in audit_events if e["metadata"]["event_id"] == event_id] matching = [e for e in audit_events if e["metadata"]["event_id"] == event_id]
assert len(matching) == 1 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"] == {}
+46 -1
View File
@@ -32,7 +32,52 @@ def test_demo_provider_health_reports_document_count():
health = provider.health() health = provider.health()
assert health.provider == "demo" assert health.provider == "demo"
assert health.available is True 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): def test_ask_question_endpoint_grounded(ops_client):
+147
View File
@@ -1,11 +1,16 @@
import threading import threading
import uuid
from datetime import UTC, datetime, timedelta
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import select from sqlalchemy import select
from app.core.db import SessionLocal from app.core.db import SessionLocal
from app.main import app from app.main import app
from app.models.audit import AuditEvent
from app.models.booking import Booking from app.models.booking import Booking
from app.models.customer import Customer
from app.models.outbox import OutboxEvent
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
@@ -39,6 +44,148 @@ def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
db.close() 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): def test_register_return_success_updates_canonical_odometer(ops_client):
booking_ref = _activate_booking("MO-003", start_odometer_km=20000) booking_ref = _activate_booking("MO-003", start_odometer_km=20000)
vehicle_before = ops_client.get("/api/v1/vehicles/MO-003").json() vehicle_before = ops_client.get("/api/v1/vehicles/MO-003").json()
+57
View File
@@ -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"] == []
+118 -1
View File
@@ -1,13 +1,16 @@
from datetime import UTC, datetime
from sqlalchemy import func, select from sqlalchemy import func, select
from app.core.db import SessionLocal from app.core.db import SessionLocal
from app.models.audit import AuditEvent
from app.models.booking import Booking from app.models.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
from app.models.outbox import OutboxEvent from app.models.outbox import OutboxEvent
from app.models.user import User from app.models.user import User
from app.models.vehicle import Vehicle 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(): def test_seed_counts_match_deterministic_dataset():
@@ -52,3 +55,117 @@ def test_seed_demo_scenarios_present():
assert failed_run is not None assert failed_run is not None
finally: finally:
db.close() 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()
+3 -1
View File
@@ -24,7 +24,6 @@ services:
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops} DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops}
TZ: ${TZ:-Europe/Brussels} TZ: ${TZ:-Europe/Brussels}
APP_SECRET: ${APP_SECRET:-replace-in-production} APP_SECRET: ${APP_SECRET:-replace-in-production}
DEMO_TODAY: ${DEMO_TODAY:-2026-08-01}
CORS_ALLOW_ORIGINS: ${MOBILITYOPS_PUBLIC_URL:-http://localhost:1228} CORS_ALLOW_ORIGINS: ${MOBILITYOPS_PUBLIC_URL:-http://localhost:1228}
KNOWLEDGE_PROVIDER: ${KNOWLEDGE_PROVIDER:-demo} KNOWLEDGE_PROVIDER: ${KNOWLEDGE_PROVIDER:-demo}
RAGCORE_BASE_URL: ${RAGCORE_BASE_URL:-http://ragcore-api:8000} 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_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return}
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token} N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-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: ports:
- "8128:8000" - "8128:8000"
depends_on: depends_on:
+128
View File
@@ -66,6 +66,26 @@ paths:
responses: responses:
'200': '200':
description: Booking detail 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: /api/v1/bookings/{public_ref}/return:
post: post:
operationId: registerVehicleReturn operationId: registerVehicleReturn
@@ -94,9 +114,17 @@ paths:
/api/v1/data-quality/issues: /api/v1/data-quality/issues:
get: get:
operationId: listDataQualityIssues operationId: listDataQualityIssues
description: Operations Manager only.
responses: responses:
'200': '200':
description: Quality issues 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: /api/v1/data-quality/issues/{public_ref}/merge-customers:
post: post:
operationId: mergeDuplicateCustomers operationId: mergeDuplicateCustomers
@@ -107,6 +135,106 @@ paths:
description: Merge completed description: Merge completed
'409': '409':
description: Issue no longer mergeable 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: /api/v1/knowledge/questions:
post: post:
operationId: askKnowledgeQuestion operationId: askKnowledgeQuestion
+46
View File
@@ -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}"
+47 -7
View File
@@ -11,9 +11,17 @@ The demo may use signed server-issued sessions or short-lived JWTs. Demo-role bu
### System and demo ### System and demo
- `GET /health` - `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/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 ### 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`
- `GET /api/v1/bookings/{public_ref}` - `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` - `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 ### Data quality
All Operations Manager only.
- `GET /api/v1/data-quality/issues` - `GET /api/v1/data-quality/issues`
- `GET /api/v1/data-quality/issues/{public_ref}` - `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}/defer`
- `POST /api/v1/data-quality/issues/{public_ref}/reject` - `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 ### Knowledge
@@ -47,9 +75,21 @@ Return commands require an `Idempotency-Key` header and optimistic version where
### Automation and audit ### Automation and audit
- `GET /api/v1/workflows` - `GET /api/v1/workflows` — Operations Manager only
- `POST /api/v1/workflows/{event_id}/retry` - `POST /api/v1/workflows/{event_id}/retry` — Operations Manager only
- `GET /api/v1/audit` - `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 ### MCP-provider endpoints
+26 -1
View File
@@ -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. 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 ## DQ-03 Odometer regression
Flag an inspection or maintenance reading below the canonical odometer. Never lower the canonical value automatically. 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 ## 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. 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 ## DQ-05 Vehicle status conflict
Examples: Examples:
@@ -42,6 +57,16 @@ Examples:
- status `available` while critical open quality issue exists; - status `available` while critical open quality issue exists;
- status `maintenance` with an active booking. - 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 ## 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.
+11
View File
@@ -1,5 +1,16 @@
# Vehicle-return workflow # 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 ## Input
- booking public reference; - booking public reference;
+30 -2
View File
@@ -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. 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. Input: manual trigger or manifest-changed event.
@@ -28,7 +55,8 @@ Steps:
2. call RAGcore ingestion/sync API; 2. call RAGcore ingestion/sync API;
3. record per-document results through MobilityOps integration status 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 ## Outbox dispatcher
+31
View File
@@ -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. 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 ## Service authentication
Use separate scoped credentials for: 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. 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 ## Confirmation
No write-capable MCP actions exist in this PoC. Destructive UI actions such as demo reset and customer merge require explicit confirmation. No write-capable MCP actions exist in this PoC. Destructive UI actions such as demo reset and customer merge require explicit confirmation.
+14
View File
@@ -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. 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 ## Demo reset
Reset must: Reset must:
@@ -49,6 +62,7 @@ Reset must:
- require Operations Manager; - require Operations Manager;
- rebuild the deterministic dataset; - rebuild the deterministic dataset;
- re-establish scenario references; - re-establish scenario references;
- re-anchor scenario dates to the real reset moment (see above);
- clear non-seed audit/workflow state; - clear non-seed audit/workflow state;
- complete safely and visibly; - complete safely and visibly;
- be covered by a test. - be covered by a test.
+41
View File
@@ -1,5 +1,9 @@
# PoC runbook # 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) ## Bootstrap (clean checkout)
```bash ```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 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. `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 ### 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 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 proxy, so the shared n8n container does not need direct database access or membership of
the MobilityOps Docker network. 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 ## Required operational checks
- API and web health (`GET /health`, web root `200`); - API and web health (`GET /health`, web root `200`);
+157
View File
@@ -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.
+77
View File
@@ -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.
+83
View File
@@ -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.
+71
View File
@@ -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.
+97
View File
@@ -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 13: 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.
+71
View File
@@ -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.
+173
View File
@@ -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 F1F4 now (Batch 1, in progress), continue through F5F14 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$/);
}
});
+1 -1
View File
@@ -14,7 +14,7 @@ test("capture the seven main pages", async ({ page, request }) => {
await page.goto("/login"); await page.goto("/login");
await page.screenshot({ path: `${OUT}/1-login.png` }); 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 expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true }); await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
+105
View File
@@ -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();
});
+87
View File
@@ -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([]);
});
+61
View File
@@ -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();
});
+163
View File
@@ -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/);
});
+77
View File
@@ -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();
});
+29 -32
View File
@@ -1,13 +1,11 @@
import { expect, test, type APIRequestContext } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test";
const API_BASE = process.env.MOBILITYOPS_API_URL ?? "http://localhost:8128";
async function resetDemoData(request: APIRequestContext) { 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" }, data: { role: "operations_manager" },
}); });
expect(login.ok()).toBeTruthy(); 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(); 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 test.step("1. login as Operations Manager", async () => {
await page.goto("/login"); await page.goto("/login");
await expect(page.getByText(/Synthetic proof of concept/)).toBeVisible(); await expect(page.getByText(/Synthetische demo/)).toBeVisible();
await page.getByRole("button", { name: "Open as Operations Manager" }).click(); await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
}); });
await test.step("2. verify dashboard metrics are loaded", async () => { 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"); const metricValues = page.locator(".metric-cell dd");
await expect(metricValues.first()).toBeVisible(); await expect(metricValues.first()).toBeVisible();
const values = await metricValues.allTextContents(); 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 test.step("3. open active demo booking", async () => {
await page.goto("/bookings/BK-DEMO-RETURN"); await page.goto("/bookings/BK-DEMO-RETURN");
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible(); 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 () => { await test.step("4. register an odometer-regression return (S1)", async () => {
const vehicleOdometerText = await page const vehicleOdometerText = await page
.locator(".detail-grid div", { hasText: "Start odometer" }) .locator(".detail-grid div", { hasText: "Startkilometerstand" })
.locator("dd") .locator("dd")
.textContent(); .textContent();
const startOdometer = parseInt((vehicleOdometerText ?? "0").replace(/\D/g, ""), 10); const startOdometer = parseInt((vehicleOdometerText ?? "0").replace(/\D/g, ""), 10);
const lowReading = Math.max(0, startOdometer - 500); const lowReading = Math.max(0, startOdometer - 500);
await page.getByLabel("End odometer (km)").fill(String(lowReading)); await page.getByLabel("Eindkilometerstand (km)").fill(String(lowReading));
await page.getByLabel("Fuel level (%)").fill("55"); await page.getByLabel("Brandstofniveau (%)").fill("55");
await page.getByRole("button", { name: "Review return" }).click(); await page.getByRole("button", { name: "Retour nakijken" }).click();
await expect(page.getByRole("heading", { name: "Review return impact" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Retourimpact nakijken" })).toBeVisible();
await page.getByRole("button", { name: "Confirm return" }).click(); 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 test.step("5. verify quality issue and queued automation event", async () => {
await expect(page.getByText(/DQ-RET-|None created/)).toBeVisible(); await expect(page.getByText(/DQ-RET-|Geen aangemaakt/)).toBeVisible();
await expect(page.getByText(/Queued \(/)).toBeVisible(); await expect(page.getByText(/Klaargezet voor verwerking \(/)).toBeVisible();
}); });
await test.step("6. resolve the duplicate customer scenario (S2)", async () => { await test.step("6. resolve the duplicate customer scenario (S2)", async () => {
await page.goto("/data-quality/DQ-DEMO-DUPLICATE"); await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
await expect(page.getByRole("heading", { name: "Compare and merge" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Vergelijken en samenvoegen" })).toBeVisible();
await page.getByRole("button", { name: /Merge into CUS-0012/ }).click(); await page.getByRole("button", { name: /Samenvoegen met CUS-0012/ }).click();
await page.getByRole("button", { name: "Yes, merge" }).click(); await page.getByRole("button", { name: "Ja, samenvoegen" }).click();
await expect(page.getByText("resolved", { exact: true })).toBeVisible(); await expect(page.locator(".badge.status-resolved")).toBeVisible();
}); });
await test.step("7. ask the damage question and inspect citations (S6)", async () => { await test.step("7. ask the damage question and inspect citations (S6)", async () => {
await page.goto("/knowledge"); await page.goto("/knowledge");
await page await page
.getByPlaceholder(/What must I do when a vehicle returns with damage/) .getByPlaceholder(/Wat moet ik doen wanneer een voertuig beschadigd terugkomt/)
.fill("What must I do when a vehicle returns with damage?"); .fill("Wat moet ik doen wanneer een voertuig terugkomt met schade?");
await page.getByRole("button", { name: "Ask" }).click(); await page.getByRole("button", { name: "Vraag stellen" }).click();
await expect(page.getByText("Grounded in cited procedures")).toBeVisible(); await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
await expect(page.getByText("Damage handling procedure").first()).toBeVisible(); await expect(page.getByText("Procedure schadeafhandeling").first()).toBeVisible();
await expect(page.getByText("Vehicle return procedure").first()).toBeVisible();
}); });
await test.step("8. inspect audit entries", async () => { await test.step("8. inspect audit entries", async () => {
await page.goto("/audit"); await page.goto("/audit");
await page.getByLabel("Action").fill("return_registered"); await page.getByLabel("Actie").fill("return_registered");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); await expect(page.locator(".audit-group-list li").first()).toBeVisible();
await expect(page.getByText("return registered").first()).toBeVisible(); await expect(page.getByText("Voertuigretour geregistreerd").first()).toBeVisible();
}); });
await test.step("9. verify responsive navigation at mobile width", async () => { await test.step("9. verify responsive navigation at mobile width", async () => {
await page.setViewportSize({ width: 360, height: 800 }); await page.setViewportSize({ width: 360, height: 800 });
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page.getByText(/Synthetic demo data/).first()).toBeVisible(); await expect(page.getByText(/Synthetische demo/).first()).toBeVisible();
await expect(page.getByRole("link", { name: "Overview" }).first()).toBeVisible(); await expect(page.getByRole("link", { name: "Overzicht" }).first()).toBeVisible();
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth); const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth); const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1); expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
+108
View File
@@ -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);
});
});
+73
View File
@@ -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);
}
}
}
}
});
+270 -15
View File
@@ -1,17 +1,19 @@
import { expect, test, type APIRequestContext } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test";
async function resetDemoData(request: APIRequestContext) { async function resetDemoData(request: APIRequestContext) {
await request.post("http://localhost:8128/api/v1/demo/login", { await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
data: { role: "operations_manager" }, await request.post("/api/v1/demo/reset");
});
await request.post("http://localhost:8128/api/v1/demo/reset");
} }
test.describe.configure({ mode: "serial" }); 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 }) => { test.beforeEach(async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
await page.goto("/login"); 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$/); 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(); 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 }) => { test("vehicle detail: all tabs render distinct content", async ({ page }) => {
await page.goto("/vehicles/MO-016"); await page.goto("/vehicles/MO-016");
await expect(page.getByRole("heading", { name: /MO-016/ })).toBeVisible(); 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(); 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 }) => { test("data quality page: status and rule-type filters work", async ({ page }) => {
await page.goto("/data-quality"); await page.goto("/data-quality");
await expect(page.locator(".data-table")).toBeVisible(); 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(); await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const rules = await page.locator(".data-table tbody tr td:nth-child(2)").allTextContents(); 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: "Rule type", exact: true }).selectOption("");
await page.getByRole("combobox", { name: "Status", exact: true }).selectOption("resolved"); 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 firstLink.click();
await expect(page.getByRole("heading", { name: ref ?? "" })).toBeVisible(); await expect(page.getByRole("heading", { name: ref ?? "" })).toBeVisible();
await page.getByRole("button", { name: "Defer" }).click(); 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 }) => { 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 }) => { test("audit page: action filter works", async ({ page }) => {
await page.goto("/audit"); 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 page.getByLabel("Action").fill("demo_login");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); await expect(page.locator(".audit-group").first()).toBeVisible();
const actions = await page.locator(".data-table tbody tr td:nth-child(3)").allTextContents(); const headings = await page.locator(".audit-group-heading strong").allTextContents();
expect(actions.every((a) => a.includes("demo login"))).toBeTruthy(); 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 }) => { 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$/); 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, page,
}) => { }) => {
await page.getByRole("button", { name: "Switch role" }).click(); 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 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( await expect(
page.getByText("Automation delivery status is visible to Operations Managers only."), page.getByText("Automation is visible to Operations Managers only.").first(),
).toBeVisible(); ).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);
}
}); });
+57
View File
@@ -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,
);
});
}
});
}
+55 -13
View File
@@ -1,16 +1,18 @@
import { expect, test, type APIRequestContext } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test";
const API_BASE = process.env.MOBILITYOPS_API_URL ?? "http://localhost:8128";
async function resetDemoData(request: APIRequestContext) { async function resetDemoData(request: APIRequestContext) {
await request.post(`${API_BASE}/api/v1/demo/login`, { data: { role: "operations_manager" } }); await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post(`${API_BASE}/api/v1/demo/reset`); 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 }) => { test.beforeEach(async ({ page, request }) => {
await resetDemoData(request); await resetDemoData(request);
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
await page.goto("/login"); 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$/); 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"); 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"); 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 expect(search).toBeFocused();
await search.fill("MO-024"); 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).toHaveURL(/\/vehicles\/MO-024$/);
await expect(page.getByRole("heading", { name: "MO-024" })).toBeVisible(); 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 }) => { test("return review separates capture from irreversible commit", async ({ page }) => {
await page.goto("/bookings/BK-DEMO-RETURN"); await page.goto("/bookings/BK-DEMO-RETURN");
await page.getByLabel("End odometer (km)").fill("60000"); await page.getByLabel("End odometer (km)").fill("60000");
await page.getByLabel("Fuel level (%)").fill("65"); await page.getByLabel("Fuel level (%)").fill("65");
let returnRequests = 0; let commitRequests = 0;
let previewRequests = 0;
page.on("request", (request) => { 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(); await page.getByRole("button", { name: "Review return" }).click();
expect(returnRequests).toBe(0); await expect(page.getByText(/Expected fleet state/)).toBeVisible();
await expect(page.getByText("Expected fleet state")).toBeVisible(); expect(commitRequests).toBe(0);
await expect(page.getByText("Queue n8n delivery after the local commit")).toBeVisible(); // 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 page.getByRole("button", { name: "Edit details" }).click();
await expect(page.getByLabel("End odometer (km)")).toHaveValue("60000"); await expect(page.getByLabel("End odometer (km)")).toHaveValue("60000");
+3 -3
View File
@@ -1,10 +1,10 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="nl">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="MobilityOps synthetic-data operations proof of concept" /> <meta name="description" content="Fleet Ops synthetic-data operations demo" />
<title>MobilityOps</title> <title>Fleet Ops</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+85 -1
View File
@@ -8,8 +8,10 @@
"name": "mobilityops-web", "name": "mobilityops-web",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"i18next": "^26.3.6",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "^17.0.11",
"react-router-dom": "7.18.2" "react-router-dom": "7.18.2"
}, },
"devDependencies": { "devDependencies": {
@@ -255,6 +257,15 @@
"@babel/core": "^7.0.0-0" "@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": { "node_modules/@babel/template": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -1463,6 +1474,43 @@
"node": ">=6.9.0" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1661,6 +1709,33 @@
"react": "^18.3.1" "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": { "node_modules/react-refresh": {
"version": "0.14.2", "version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@@ -1794,7 +1869,7 @@
"version": "5.6.3", "version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -1835,6 +1910,15 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+2
View File
@@ -11,8 +11,10 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"i18next": "^26.3.6",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "^17.0.11",
"react-router-dom": "7.18.2" "react-router-dom": "7.18.2"
}, },
"devDependencies": { "devDependencies": {
+10
View File
@@ -1,5 +1,7 @@
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider } from "./context/AuthContext"; import { AuthProvider } from "./context/AuthContext";
import { DemoManifestProvider } from "./context/DemoManifestContext";
import { DemoGuideProvider } from "./context/DemoGuideContext";
import { Layout } from "./components/Layout"; import { Layout } from "./components/Layout";
import { RequireAuth } from "./components/RequireAuth"; import { RequireAuth } from "./components/RequireAuth";
import { Login } from "./pages/Login"; import { Login } from "./pages/Login";
@@ -13,10 +15,14 @@ import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail";
import { Automation } from "./pages/Automation"; import { Automation } from "./pages/Automation";
import { Knowledge } from "./pages/Knowledge"; import { Knowledge } from "./pages/Knowledge";
import { Audit } from "./pages/Audit"; import { Audit } from "./pages/Audit";
import { AboutDemo } from "./pages/AboutDemo";
import { Scenarios } from "./pages/Scenarios";
export function App() { export function App() {
return ( return (
<DemoManifestProvider>
<AuthProvider> <AuthProvider>
<DemoGuideProvider>
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route <Route
@@ -36,10 +42,14 @@ export function App() {
<Route path="/automation" element={<Automation />} /> <Route path="/automation" element={<Automation />} />
<Route path="/knowledge" element={<Knowledge />} /> <Route path="/knowledge" element={<Knowledge />} />
<Route path="/audit" element={<Audit />} /> <Route path="/audit" element={<Audit />} />
<Route path="/about" element={<AboutDemo />} />
<Route path="/scenarios" element={<Scenarios />} />
</Route> </Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>
</DemoGuideProvider>
</AuthProvider> </AuthProvider>
</DemoManifestProvider>
); );
} }
+11
View File
@@ -1,5 +1,13 @@
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; 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 { export class ApiError extends Error {
status: number; status: number;
code: string; code: string;
@@ -31,6 +39,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
body = undefined; body = undefined;
} }
const error = body?.error; const error = body?.error;
if (response.status === 401) {
unauthorizedListeners.forEach((listener) => listener());
}
throw new ApiError( throw new ApiError(
response.status, response.status,
error?.code ?? String(response.status), error?.code ?? String(response.status),
+95 -1
View File
@@ -88,7 +88,7 @@ export interface DashboardMetrics {
export interface AttentionItem { export interface AttentionItem {
kind: string; kind: string;
severity: "low" | "medium" | "high"; severity: "low" | "medium" | "high";
title: string; rule_type: string;
detail: string; detail: string;
link_type: "vehicle" | "booking" | "customer"; link_type: "vehicle" | "booking" | "customer";
link_ref: string; link_ref: string;
@@ -145,7 +145,22 @@ export interface RegisterReturnResult {
next_booking_risk: NextBookingRisk | null; 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 { export interface EntitySnapshot {
entity_type: "customer" | "vehicle" | "booking" | "inspection";
public_ref: string; public_ref: string;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -155,6 +170,28 @@ export interface DataQualityIssueDetail extends DataQualityIssue {
related_snapshots: EntitySnapshot[]; 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 { export interface MergeCustomersRequest {
survivor_ref: string; survivor_ref: string;
field_overrides?: Record<string, string>; field_overrides?: Record<string, string>;
@@ -193,6 +230,59 @@ export interface KnowledgeHealth {
document_count: number; 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 { export interface AuditEvent {
id: string; id: string;
actor_type: string; actor_type: string;
@@ -200,7 +290,11 @@ export interface AuditEvent {
action: string; action: string;
entity_type: string; entity_type: string;
entity_id: string | null; entity_id: string | null;
entity_ref: string | null;
entity_link: string | null;
correlation_id: string; correlation_id: string;
occurred_at: string; occurred_at: string;
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
metadata: Record<string, unknown> | null; metadata: Record<string, unknown> | null;
} }
+6 -4
View File
@@ -1,8 +1,10 @@
import { useTranslation } from "react-i18next";
export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) { export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
const label = severity === "high" ? "High" : severity === "medium" ? "Medium" : "Low"; const { t } = useTranslation("quality");
return <span className={`badge severity-${severity}`}>{label} severity</span>; return <span className={`badge severity-${severity}`}>{t(`severities.${severity}`)}</span>;
} }
export function StatusBadge({ status }: { status: string }) { export function StatusBadge({ status, label }: { status: string; label?: string }) {
return <span className={`badge status-${status}`}>{status.replace(/_/g, " ")}</span>; return <span className={`badge status-${status}`}>{label ?? status.replace(/_/g, " ")}</span>;
} }
+74
View File
@@ -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>
);
}
+248
View File
@@ -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>
);
}
+221 -79
View File
@@ -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 { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import type { Role, SearchResultItem } from "../api/types";
import { BrandMark, Icon, type IconName } from "./Icons"; 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: [ items: [
{ to: "/dashboard", label: "Overview", shortLabel: "Overview", icon: "activity" }, { to: "/dashboard", labelKey: "items.overview", icon: "activity" },
{ to: "/vehicles", label: "Fleet", shortLabel: "Fleet", icon: "fleet" }, { to: "/vehicles", labelKey: "items.fleet", icon: "fleet" },
{ to: "/bookings", label: "Bookings", shortLabel: "Bookings", icon: "bookings" }, { to: "/bookings", labelKey: "items.bookings", icon: "bookings" },
{ to: "/data-quality", label: "Data quality", shortLabel: "Quality", icon: "quality" }, { to: "/data-quality", labelKey: "items.quality", icon: "quality", roles: ["operations_manager"] },
], ],
}, },
{ {
label: "Assure", labelKey: "groups.assure",
items: [ items: [
{ to: "/knowledge", label: "Knowledge", shortLabel: "Knowledge", icon: "knowledge" }, { to: "/knowledge", labelKey: "items.knowledge", icon: "knowledge" },
{ to: "/automation", label: "Integrations", shortLabel: "Systems", icon: "integrations" }, { to: "/automation", labelKey: "items.integrations", icon: "integrations", roles: ["operations_manager"] },
{ to: "/audit", label: "Audit trail", shortLabel: "Audit", icon: "audit" }, { 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() { export function Layout() {
const { t } = useTranslation(["navigation", "common", "auth"]);
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { manifest } = useDemoManifest();
const { open: guideOpen, collapsedToChip: guideCollapsed } = useDemoGuide();
const navigate = useNavigate(); const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); 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 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(() => { useEffect(() => {
function focusGlobalSearch(event: KeyboardEvent) { function focusGlobalSearch(event: KeyboardEvent) {
@@ -55,59 +86,112 @@ export function Layout() {
return () => window.removeEventListener("keydown", focusGlobalSearch); return () => window.removeEventListener("keydown", focusGlobalSearch);
}, []); }, []);
function handleLogout() { useEffect(() => {
logout(); 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"); 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(); event.preventDefault();
const query = searchQuery.trim(); setActiveIndex((i) => (i + 1) % searchResults.length);
if (!query) { } else if (event.key === "ArrowUp") {
setSearchStatus("Enter a section or a vehicle, booking or issue reference."); event.preventDefault();
return; 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 ( return (
<div className="app-shell"> <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" : ""}`}> <aside className={`sidebar ${mobileOpen ? "is-open" : ""}`}>
<div className="brand-lockup"> <div className="brand-lockup">
<BrandMark className="brand-mark" /> <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> </div>
<nav aria-label="Primary navigation"> <div className="sidebar-language">
{NAV_GROUPS.map((group) => ( <LanguageSwitcher />
<div className="nav-group" key={group.label}> </div>
<p>{group.label}</p> <nav aria-label={t("primaryNavLabel")}>
{navGroups.map((group) => (
<div className="nav-group" key={group.labelKey}>
<p>{t(group.labelKey)}</p>
<ul> <ul>
{group.items.map((item) => ( {group.items.map((item) => (
<li key={item.to}> <li key={item.to}>
<NavLink to={item.to} onClick={() => setMobileOpen(false)}> <NavLink to={item.to} onClick={() => setMobileOpen(false)}>
<Icon name={item.icon} /> <Icon name={item.icon} />
<span>{item.label}</span> <span>{t(item.labelKey)}</span>
</NavLink> </NavLink>
</li> </li>
))} ))}
@@ -117,66 +201,124 @@ export function Layout() {
</nav> </nav>
<div className="sidebar-foot"> <div className="sidebar-foot">
<span className="environment-dot" /> <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> </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> </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"> <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" /> <Icon name="menu" />
</button> </button>
<form className="global-search" role="search" onSubmit={handleSearch}> <div className="global-search" role="search" ref={searchBox}>
<Icon name="search" /> <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 <input
id="global-search-input" id="global-search-input"
ref={searchInput} ref={searchInput}
type="search" 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} value={searchQuery}
placeholder="Search fleet, booking or section…" placeholder={t("searchPlaceholder")}
aria-describedby="global-search-status" onFocus={() => setSearchOpen(true)}
onChange={(event) => { onChange={(event) => {
setSearchQuery(event.target.value); setSearchQuery(event.target.value);
setSearchStatus(""); setSearchOpen(true);
}} }}
onKeyDown={handleSearchKeyDown}
/> />
<kbd>Ctrl K</kbd> <kbd>{t("searchShortcutHint")}</kbd>
<span id="global-search-status" className="visually-hidden" aria-live="polite">{searchStatus}</span> {searchOpen && searchQuery.trim() && (
</form> <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"> <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 && ( {user && (
<div className="operator"> <div className="operator">
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span> <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> </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" /> <Icon name="logout" />
</button> </button>
</div> </div>
</header> </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> <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> </div>
<nav className="mobile-nav" aria-label="Mobile navigation"> <nav className="mobile-nav" aria-label={t("mobileNavLabel")}>
{MOBILE_ITEMS.map((item) => ( {mobileItems.map((item) => (
<NavLink key={item.to} to={item.to}> <NavLink key={item.to} to={item.to}>
<Icon name={item.icon} /> <Icon name={item.icon} />
<span>{item.shortLabel}</span> <span>{t(item.labelKey)}</span>
</NavLink> </NavLink>
))} ))}
<button type="button" onClick={() => setMobileOpen(true)}> <button type="button" onClick={() => setMobileOpen(true)}>
<Icon name="menu" /> <Icon name="menu" />
<span>More</span> <span>{t("more")}</span>
</button> </button>
</nav> </nav>
<DemoGuide />
</div> </div>
); );
} }
+9 -4
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Icon, type IconName } from "./Icons"; import { Icon, type IconName } from "./Icons";
export function PageHeader({ export function PageHeader({
@@ -28,15 +29,17 @@ export function SectionHeading({
title, title,
description, description,
action, action,
headingId,
}: { }: {
title: string; title: string;
description?: string; description?: string;
action?: ReactNode; action?: ReactNode;
headingId?: string;
}) { }) {
return ( return (
<div className="section-heading"> <div className="section-heading">
<div> <div>
<h2>{title}</h2> <h2 id={headingId}>{title}</h2>
{description && <p>{description}</p>} {description && <p>{description}</p>}
</div> </div>
{action} {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 ( return (
<div className="state-panel" role="status"> <div className="state-panel" role="status">
<span className="spinner" aria-hidden="true" /> <span className="spinner" aria-hidden="true" />
<p>{label}</p> <p>{label ?? t("states.loadingDefault")}</p>
</div> </div>
); );
} }
export function ErrorState({ message }: { message: string }) { export function ErrorState({ message }: { message: string }) {
const { t } = useTranslation("common");
return ( return (
<div className="state-panel state-error" role="alert"> <div className="state-panel state-error" role="alert">
<Icon name="alert" /> <Icon name="alert" />
<div><strong>We couldnt load this workspace.</strong><p>{message}</p></div> <div><strong>{t("states.errorTitle")}</strong><p>{message}</p></div>
</div> </div>
); );
} }
+9 -1
View File
@@ -1,9 +1,17 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { LoadingState } from "./PageChrome";
export function RequireAuth({ children }: { children: ReactNode }) { export function RequireAuth({ children }: { children: ReactNode }) {
const { user } = useAuth(); const { user, loading } = useAuth();
if (loading) {
return (
<div className="page">
<LoadingState />
</div>
);
}
if (!user) { if (!user) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }

Some files were not shown because too many files have changed in this diff Show More